Compare commits
131 Commits
661841ceef
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d2bea9180a | |||
| f0b241f37d | |||
| f76f4d5673 | |||
| 72b6f26ebc | |||
| b06ce4eefd | |||
| 49317e905b | |||
| 189c896e60 | |||
| 4ae0b321b9 | |||
| 5a3b87e82c | |||
| 0731ac3b6e | |||
| 77fa4cde35 | |||
| 9aac1d3e34 | |||
| 7aced9477c | |||
| 5a0327c930 | |||
| 18e4fcea28 | |||
| 239ab2e241 | |||
| 9fcead8c95 | |||
| 73871ae04e | |||
| c35c63b421 | |||
| 9fd201c10a | |||
| cb81e233a5 | |||
| 40648c3fdf | |||
| 6248248f0e | |||
| 7bbd607376 | |||
| 4f5d5425fc | |||
| d48edbbf5f | |||
| 0d337849c7 | |||
| 717a0ed830 | |||
| 990e7fa226 | |||
| 5a8050145d | |||
| 1cc6e1e85d | |||
| 765c0749bb | |||
| 5080e5fdde | |||
| 2fed7fa4e7 | |||
| 0613efa846 | |||
| c8466c557d | |||
| 0d7cad945c | |||
| ea5093f2c2 | |||
| b542fd40a6 | |||
| a9e9f97c14 | |||
| 90a46ada2d | |||
| 4f0d943e04 | |||
| 04ff66f187 | |||
| d28092874f | |||
| d0e720f469 | |||
| 3dc91b329f | |||
| 2f2c5a035d | |||
| 32ce981d40 | |||
| e7a4389fde | |||
| f03f8374c8 | |||
| 78ffcb9b71 | |||
| 0a657dca4b | |||
| 8941d69ba4 | |||
| b8d9e8e804 | |||
| 4bf414112a | |||
| 6cc99812d2 | |||
| 2b88e46ca6 | |||
| 2c23992e52 | |||
| ba46c10449 | |||
| f878fa7886 | |||
| ef43369425 | |||
| 37193d2246 | |||
| 87f9119b0b | |||
| f9b6aae927 | |||
| 6683d60be5 | |||
| 3942c54ad9 | |||
| 0a22bc1b8f | |||
| 237f8d5742 | |||
| 8d72172e0a | |||
| 90aa5e06f2 | |||
| 178f27cfe2 | |||
| f4c6cdab3b | |||
| 40e20d0e64 | |||
| 3a5a299b53 | |||
| 76143a9c2f | |||
| 1ba9a66c8e | |||
| 14aea2a475 | |||
| d38bb7d986 | |||
| f71dccf166 | |||
| cc73fc4af2 | |||
| 76a5c0b454 | |||
| 2b7a92097c | |||
| c9f8c69e42 | |||
| bc41823dc3 | |||
| 6c2047033b | |||
| 6c3f6c8d03 | |||
| e01430f60e | |||
| 9d835d49d9 | |||
| 081b3d08dd | |||
| 614dc19095 | |||
| f0bf294d3d | |||
| 272a1f61af | |||
| 7b6ca62d9a | |||
| 9e823d003e | |||
| 309b55f25f | |||
| ee9eb97262 | |||
| 484246bd5d | |||
| 71b9c42265 | |||
| bf02c4c10d | |||
| 3005c94010 | |||
| 6c480a4971 | |||
| 3fbb82642b | |||
| c90acc2765 | |||
| 8f950ed665 | |||
| 2249791267 | |||
| 8766be57d0 | |||
| 4b612fa7fe | |||
| 155a5edd7d | |||
| fc5c63fe87 | |||
| ef37d28e1b | |||
| e1e8ad79b4 | |||
| 654cf34c74 | |||
| 13c5e078a8 | |||
| 5bdb754f1c | |||
| 15b77ff09f | |||
| b0c4128e01 | |||
| e5a2539ec6 | |||
| 8f9c1833b0 | |||
| c7e946f963 | |||
| 9ef599bbd5 | |||
| ab3947b4a9 | |||
| b6f1e3eb8e | |||
| b04ee4cb92 | |||
| 4e613554e6 | |||
| d1718becde | |||
| ef9ae99cb6 | |||
| 72a8e7fb91 | |||
| f1ad5d2965 | |||
| cda4f5654a | |||
| f1fe81a146 | |||
| bc059de65b |
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -3,5 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="Python 3.10" />
|
<option name="sdkName" value="Python 3.10" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Remote Python 3.13.2 Docker Compose (api)" project-jdk-type="Python SDK" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="Remote Python 3.13.3 Docker Compose (api)" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
2
.idea/roleplay-contracts.iml
generated
2
.idea/roleplay-contracts.iml
generated
@@ -5,7 +5,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/api/rpk-api" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/api/rpk-api" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/api/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/api/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Remote Python 3.13.2 Docker Compose (api)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Remote Python 3.13.3 Docker Compose (api)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
355
api/fixtures/entities.js
Normal file
355
api/fixtures/entities.js
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
db.Entity.insertMany([
|
||||||
|
{
|
||||||
|
"_id": ObjectId("640e297191d0c1b8d9caa30c"),
|
||||||
|
"address": "Barbareno Road - Chumash, SA",
|
||||||
|
"created_at": "2023-03-12T18:46:23.366Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Nathaniel",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Toshi",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": "1992-08-11T00:00:00.000Z",
|
||||||
|
"place_of_birth": "Port Angeles, WA"
|
||||||
|
},
|
||||||
|
"label": "Nathaniel Toshi",
|
||||||
|
"updated_at": "2023-03-12T19:35:13.750Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("640e5b7391d0c1b8d9caa311"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-12T18:46:23.366Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Nina",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Domingo",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Nina Cooper",
|
||||||
|
"updated_at": "2023-03-12T23:08:35.082Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("640e5bb991d0c1b8d9caa312"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-12T18:46:23.366Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Derek",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Hillman",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Derek Hillman",
|
||||||
|
"updated_at": "2023-03-12T23:09:45.939Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105c7642f24b3cb93244cc"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Andrew",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Moore",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": "2002-03-12T00:00:00.000Z",
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Johnny Blackhand",
|
||||||
|
"updated_at": "2023-03-14T11:37:26.398Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105ce142f24b3cb93244ce"),
|
||||||
|
"address": "El Rancho Boulevard - Los Santos, SA",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "corporation",
|
||||||
|
"title": "East Custom",
|
||||||
|
"activity": "réparation de véhicule",
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"position": "Dirigeant",
|
||||||
|
"entity_id": ObjectId("64105c7642f24b3cb93244cc")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": "Benny's",
|
||||||
|
"updated_at": "2023-03-14T11:39:13.143Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410721842f24b3cb93244da"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Gary",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Terry",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Gary Terry",
|
||||||
|
"updated_at": "2023-03-14T13:09:44.325Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410724342f24b3cb93244dc"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Wayne",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Terry",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Wayne Terry",
|
||||||
|
"updated_at": "2023-03-14T13:10:27.808Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e1b1c64aae93833c8f67"),
|
||||||
|
"address": "South Boulevard Del Perro - Los Santos, SA",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "corporation",
|
||||||
|
"title": "ROCKFORD",
|
||||||
|
"activity": "production et diffusion musicale",
|
||||||
|
"employees": []
|
||||||
|
},
|
||||||
|
"label": "RockFord",
|
||||||
|
"updated_at": "2023-03-14T21:05:53.499Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e21dc64aae93833c8f68"),
|
||||||
|
"address": "Murrieta Street - Los Santos, SA",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Lay",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Parks",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": "2003-07-26T00:00:00.000Z",
|
||||||
|
"place_of_birth": "Philadelphie, PN"
|
||||||
|
},
|
||||||
|
"label": "Lay Parks",
|
||||||
|
"updated_at": "2023-03-14T21:07:41.072Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e2aec64aae93833c8f69"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Franklin",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Woods",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Franklin Woods",
|
||||||
|
"updated_at": "2023-03-14T21:10:06.643Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64132ba1dfeec019e7312277"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Richard",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Malfaisant",
|
||||||
|
"surnames": ["Mauvaisefoi", "Ricard Malaisant", "Faisantmal"],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Richard \"Mauvaisefoi\" Malfaisant",
|
||||||
|
"updated_at": "2023-03-16T14:45:53.483Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6413493fdfeec019e731227e"),
|
||||||
|
"address": "Eclipse Tower",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "corporation",
|
||||||
|
"title": "LSHD",
|
||||||
|
"activity": "Hopital",
|
||||||
|
"employees": []
|
||||||
|
},
|
||||||
|
"label": "LSHD",
|
||||||
|
"updated_at": "2023-03-16T16:52:15.119Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64134981dfeec019e731227f"),
|
||||||
|
"address": "Avenue San Andreas",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "corporation",
|
||||||
|
"title": "BurgerShot",
|
||||||
|
"activity": "Restaurant",
|
||||||
|
"employees": []
|
||||||
|
},
|
||||||
|
"label": "BurgerShot",
|
||||||
|
"updated_at": "2023-03-16T16:53:21.827Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("641349bddfeec019e7312280"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Angelo",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Martinez",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Angelo Martinez",
|
||||||
|
"updated_at": "2023-03-16T16:54:21.552Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64134a22dfeec019e7312281"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Naytia",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Galloway",
|
||||||
|
"surnames": ["Nana"],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Naytia \"Nana\" Galloway",
|
||||||
|
"updated_at": "2023-03-16T16:56:02.133Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("641390fbdfeec019e7312286"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Ezrah",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "KREIGHTON",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Ezrah KREIGHTON",
|
||||||
|
"updated_at": "2023-03-16T21:58:19.571Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6414920ddfeec019e7312288"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Kono",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Taylor",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Kono Taylor",
|
||||||
|
"updated_at": "2023-03-17T16:15:09.367Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6414b4e1dfeec019e731228b"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Travis",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Ndiaye",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": null,
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Travis Ndiaye",
|
||||||
|
"updated_at": "2023-03-17T18:43:45.434Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6414bcd2dfeec019e731228e"),
|
||||||
|
"address": "Grove Street - Los Santos, SA",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Junior",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Myles",
|
||||||
|
"surnames": ["Juicy"],
|
||||||
|
"day_of_birth": "1998-09-16T00:00:00.000Z",
|
||||||
|
"place_of_birth": "Los Santos, SA"
|
||||||
|
},
|
||||||
|
"label": "Junior \"Juicy\" Miles",
|
||||||
|
"updated_at": "2023-03-17T19:17:38.654Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64175041e0e97628a53dc362"),
|
||||||
|
"address": "",
|
||||||
|
"created_at": "2023-03-19T13:00:28.665Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"entity_data": {
|
||||||
|
"type": "individual",
|
||||||
|
"firstname": "Cranston",
|
||||||
|
"middlename": "",
|
||||||
|
"lastname": "Bennett",
|
||||||
|
"surnames": [],
|
||||||
|
"day_of_birth": "1999-02-14T00:00:00.000Z",
|
||||||
|
"place_of_birth": ""
|
||||||
|
},
|
||||||
|
"label": "Cranston Bennett",
|
||||||
|
"updated_at": "2023-03-19T18:11:13.712Z",
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
}
|
||||||
|
])
|
||||||
220
api/fixtures/provision_templates.js
Normal file
220
api/fixtures/provision_templates.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
db.ProvisionTemplate.insertMany([
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105db142f24b3cb93244cf"),
|
||||||
|
"body": "<p>Le présent contrat est conclu pour une durée indéterminée. <br>Aucune période d'essai n'est prévue dans le présent contrat.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - durée du contrat (indéterminée) - \"Durée du contrat\"",
|
||||||
|
"name": "Contrat de travail - durée du contrat (indéterminée - sans perriode d'essai)",
|
||||||
|
"title": "Durée du contrat",
|
||||||
|
"updated_at": "2023-03-14T11:42:41.347Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105dee42f24b3cb93244d0"),
|
||||||
|
"body": "<p>Le lieu de travail habituel est situé à <strong>%ADRESSE_LIEU_DE_TRAVAIL%</strong>. Il se peut que dans le cadre d’évènements, <strong>L’EMPLOYÉ·E</strong> soit amené à travailler à l’extérieur du lieu de travail.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - Lieu de travail - \"Lieu de travail\"",
|
||||||
|
"name": "Contrat de travail - Lieu de travail",
|
||||||
|
"title": "Lieu de travail",
|
||||||
|
"updated_at": "2023-03-14T11:43:42.786Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105e6942f24b3cb93244d1"),
|
||||||
|
"body": "<p id=\"docs-internal-guid-00a7ebe9-7fff-589c-16c6-32415839200c\" dir=\"ltr\">La rémunération nette horaire est déterminée en fonction des bénéfices imputables à <strong>L’EMPLOYÉ·E</strong> au cours de la semaine.</p>\n<p dir=\"ltr\"><strong>L’EMPLOYÉ·E</strong> obtiendra également une prime hebdomadaire en fonction du travail fourni.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - Rémunération et avantages (selon les bénéfices) - \"Rémunération et avantages\"",
|
||||||
|
"name": "Contrat de travail - Rémunération et avantages (selon les bénéfices)",
|
||||||
|
"title": "Rémunération et avantages",
|
||||||
|
"updated_at": "2023-03-14T11:45:45.465Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105eb942f24b3cb93244d2"),
|
||||||
|
"body": "<p>La prise de service est libre, mais cependant, <strong>L’EMPLOYÉ·E</strong> devra prévenir <strong>L’EMPLOYEUR</strong> au plus tôt 2 jours de toute absence prolongée, ou d’indisponibilité pour raison personnelle ou impérieuse. Sous peine de sanction ou de de licenciement.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contat de travail - Durée du travail et service - \"Durée du travail et service\"",
|
||||||
|
"name": "Contrat de travail - Durée du travail et service",
|
||||||
|
"title": "Durée du travail et service",
|
||||||
|
"updated_at": "2023-03-14T11:47:05.528Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105f3642f24b3cb93244d3"),
|
||||||
|
"body": "<p id=\"docs-internal-guid-98edddc5-7fff-e483-a888-efc9298b4efd\" dir=\"ltr\">Pendant toute la durée du contrat, <strong>L’EMPLOYÉ·E</strong> s’engage à consacrer professionnellement toute son activité et tous ses soins à l’entreprise, à observer le règlement intérieur, toutes les consignes et instructions particulières de travail qui lui seront données.</p>\n<p dir=\"ltr\"><strong>L’EMPLOYÉ·E</strong> conserve la propriété des moyens techniques, outils, méthodes ou savoir-faire (à l'exclusion des moyens techniques, outils, méthodes, procédés et savoir-faire qui lui ont été transmis par <strong>L’EMPLOYEUR</strong>)</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contat de travail - Obligations professionnelles - \"Obligations professionnelles\"",
|
||||||
|
"name": "Contrat de travail - Obligations professionnelles",
|
||||||
|
"title": "Obligations professionnelles",
|
||||||
|
"updated_at": "2023-03-14T11:49:10.015Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("64105ff042f24b3cb93244d4"),
|
||||||
|
"body": "<p><strong>L’EMPLOYÉ·E</strong> aura pour mission principale de %MISSION_PRINCIPALE%.<br>La liste des missions n’est pas exhaustive. <strong>L’EMPLOYEUR</strong> se réserve le droit de faire exécuter à <strong>L’EMPLOYÉ·E</strong> toute tâche nécessaire au bon fonctionnement de l’entreprise.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - Missions - \"Missions\"",
|
||||||
|
"name": "Contrat de travail - Missions",
|
||||||
|
"title": "Missions",
|
||||||
|
"updated_at": "2023-03-14T11:52:16.531Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410605342f24b3cb93244d5"),
|
||||||
|
"body": "<p>Afin d’exécuter les tâches qui lui sont affectées, <strong>L’EMPLOYÉ·E</strong> aura à sa disposition plusieurs véhicules qui restent à la propriété de l´entreprise.</p>\n<p>L’entretien des véhicules est à la charge de l´entreprise .</p>\n<p>En cas de panne ou d’accident avec un des véhicules, les frais engagés seront pris en charge par l´entreprise</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - - \"Clauses relatives au matériel\"",
|
||||||
|
"name": "Contrat de travail - Clauses relatives au matériel",
|
||||||
|
"title": "Clauses relatives au matériel",
|
||||||
|
"updated_at": "2023-03-14T11:53:55.804Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("641060a042f24b3cb93244d6"),
|
||||||
|
"body": "<p>Chacune des parties pourra mettre fin au présent contrat, sous réserve de respecter les règles prévues à cet effet par la loi et de prévenir l’autre partie de sa décision par lettre recommandée avec accusé de réception. </p>\n<p>En cas d’ajout au casier judiciaire après l’embauche de <strong>L’EMPLOYÉ·E</strong>, celui-ci pourra être, à la discretion de <strong>L’EMPLOYEUR</strong> licencié.<br>La période de préavis pour les deux parties est de 48 heures.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - Rupture de contrat - \"Rupture de contrat\"",
|
||||||
|
"name": "Contrat de travail - Rupture de contrat",
|
||||||
|
"title": "Rupture de contrat",
|
||||||
|
"updated_at": "2023-03-14T11:55:12.879Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("641060f242f24b3cb93244d7"),
|
||||||
|
"body": "<p><strong>L’EMPLOYÉ·E</strong> est tenu·e à une obligation de bonne foi dans l’exercice de ses fonctions.</p>\n<p>Sous peine d’encourir des sanctions disciplinaires voire pénales, <strong>L’EMPLOYÉ·E</strong> s’engage à ne pas divulguer d’informations confidentielles sur l'entreprise ainsi qu'à garder le secret le plus absolu pendant la durée de son service et après l'avoir quitté, y compris après la rupture éventuelle de l'engagement contractuel présent, sur tout ce qui a été appris ou communiqué, par n'importe quel moyen -verbal ou écrit, que ce soit à l'intérieur ou à l'extérieur de l'entreprise.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - Clause de loyauté, de bonne foi et de confidentialité - \"Clause de loyauté, de bonne foi et de confidentialité \"",
|
||||||
|
"name": "Contrat de travail - Clause de loyauté, de bonne foi et de confidentialité ",
|
||||||
|
"title": "Clause de loyauté, de bonne foi et de confidentialité ",
|
||||||
|
"updated_at": "2023-03-14T11:56:34.871Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410715742f24b3cb93244d9"),
|
||||||
|
"body": "<p>Le présent contrat est conclu pour une durée de <strong>%DUREE_DU_CONTRAT%</strong>.</p>\n<p>A l'expiration de cette perriode, un nouveau contrat devra être établi entre les deux parties.</p>",
|
||||||
|
"created_at": "2023-03-13T18:03:45.999Z",
|
||||||
|
"label": "Contrat de travail - durée du contrat (déterminée) - \"Durée du contrat\"",
|
||||||
|
"name": "Contrat de travail - durée du contrat (déterminée)",
|
||||||
|
"title": "Durée du contrat",
|
||||||
|
"updated_at": "2023-03-14T13:06:31.373Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e4c7c64aae93833c8f6a"),
|
||||||
|
"body": "<p>Les <strong>parties</strong> conviennent des définitions suivantes :</p>\n<p><strong>Album </strong>: ensemble constitué d’une succession d’un minimum de 10 (dix) phonogrammes pour une durée totale d'enregistrement d’au moins 40 (quarante) minutes.</p>\n<p><strong>Album Inédit Studio</strong> : Album d’œuvres musicales nouvelles et inédites (paroles et musiques), chantées - enregistrées en studio et interprétées par l’ARTISTE en toute langue préalablement agréée par écrit par le PRODUCTEUR.</p>\n<p><strong>Date de sortie commerciale</strong> : Date de mise à disposition du public des enregistrements objet des présentes, telle que formalisée par la feuille d'information publiée par le PRODUCTEUR à destination de sa clientèle</p>\n<p><strong>Durée d’exclusivité de fixation</strong> : Durée pendant laquelle l’ARTISTE consent au PRODUCTEUR l’exclusivité de fixation de ses prestations, telle qu’elle est définie à l’article 4 du présent contrat.</p>\n<p><strong>Phonogramme </strong>: Toute fixation exclusivement sonore de sons provenant de l’exécution<br>instrumentale et/ou l’interprétation vocale de toute œuvre musicale avec ou sans paroles, quels qu’en soient le procédé d’enregistrement et la destination.</p>\n<p><strong>Enregistrement</strong>: Fixation de sons ou d’images sonorisées ou non d’une prestation de l’ARTISTE, quels qu’en soient le procédé d’enregistrement et la destination.</p>\n<p><strong>Bande mère</strong> : Support original (incluant les multipistes) contenant les versions définitives mixées et masterisées d’un phonogramme ou d’un vidéogramme ou d’un ensemble constitué d’une succession de phonogrammes et/ou de vidéogrammes permettant les opérations de production.</p>\n<p><strong>Maquette </strong>: Version pré-produite d’un titre enregistré par l’ARTISTE en studio, d’une qualité technique et artistique suffisamment élaborée pour permettre au PRODUCTEUR d’avoir une idée précise de ce que sera la version définitive dudit titre une fois produit.Mise à disposition du public : toutes modalités de distribution par la vente, l’échange ou le louage y compris toute forme de distribution par réseaux numériques de données, des phonogrammes et vidéogrammes, mettant en œuvre le droit d’autorisation préalable du PRODUCTEUR en qualité de producteur au sens de la propriété intellectuelle.</p>\n<p><strong>Réalisateur artistique</strong> : Personne responsable de l'organisation des sessions d'enregistrement, du contrôle de la qualité des enregistrements et qui assure leur bonne fin et toutes autres tâches similaires ou annexes.</p>\n<p><strong>Single </strong>: Programme constitué d’une succession d’un maximum de 3 (trois) phonogrammes d’une durée minimale chacun de 3 minutes.</p>\n<p><strong>Support Phonographique</strong> : Tout support matériel permettant la fixation et/ou la reproduction du son, quel qu’en soit le procédé d’enregistrement connu ou inconnu à ce jour, quelle que soit la nature du support mécanique, magnétique, acoustique, numérique, optique ou autres, et quelle que soit la destination.</p>\n<p><strong>Territoire </strong>: Le monde entier.</p>\n<p><strong>Vidéogramme </strong>: Toute fixation de séquences d’images sonorisées ou non quels qu’en soient le procédé d’enregistrement et la destination.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Définitions - \"Définitions\"",
|
||||||
|
"name": "Contrat d'exploitation - Définitions",
|
||||||
|
"title": "Définitions",
|
||||||
|
"updated_at": "2023-03-14T21:19:03.050Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e51fc64aae93833c8f6b"),
|
||||||
|
"body": "<p id=\"docs-internal-guid-3986f82b-7fff-5815-55ab-34db6ee4d510\" dir=\"ltr\" style=\"text-align: justify;\"><strong>2-1</strong> Le <strong>PRODUCTEUR </strong>engage <strong>l'ARTISTE </strong>en vue de la fixation et de l’exploitation par quelque procédé que ce soit de ses prestations artistiques à des fins commerciales et promotionnelles.</p>\n<p dir=\"ltr\" style=\"text-align: justify;\">Le présent contrat est conclu en conformité avec les règles relatives aux contrats à durée</p>\n<p dir=\"ltr\" style=\"text-align: justify;\">indéterminée dits \"d'usage\" dans le secteur de l'édition phonographique.Il s'applique à un emploi à caractère artistique, par nature indéterminé.</p>\n<p dir=\"ltr\" style=\"text-align: justify;\"><strong>2-2</strong> <strong>L'ARTISTE</strong>, qui se déclare libre de tout engagement similaire, accorde au <strong>PRODUCTEUR </strong>l'exclusivité de la fixation de ses prestations pour le monde entier en toutes langues, en vue de leur communication par tous moyens connus ou à venir par tous procédés actuels ou à venir, notamment sur des supports matériels ou non quelconques les reproduisant pour une publication commerciale ou non commerciale.</p>\n<p><strong>L'ARTISTE </strong>certifie ne pas être lié à ce jour par un quelconque contrat interdisant ou pouvant gêner la conclusion et/ou l’exécution des présentes et garantit le <strong>PRODUCTEUR </strong>contre tout recours et/ou réclamation à cet égard.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Objet - \"Objet\"",
|
||||||
|
"name": "Contrat d'exploitation - Objet",
|
||||||
|
"title": "Objet",
|
||||||
|
"updated_at": "2023-03-14T21:20:31.260Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e540c64aae93833c8f6c"),
|
||||||
|
"body": "<p id=\"docs-internal-guid-f2311b4a-7fff-0b9a-c6fb-7b4cd4f3f8f6\" dir=\"ltr\" style=\"text-align: justify;\"><strong>3-1</strong> Compte tenu des investissements consentis par le <strong>PRODUCTEUR </strong>pour la production et la promotion des enregistrements de <strong>l'ARTISTE </strong>produits dans le cadre du présent contrat, le<strong> LABEL </strong>s’interdit, à l’expiration de la Durée d’exclusivité de fixation, d'enregistrer, produire, distribuer ou vendre, soit pour son propre compte, soit pour le compte d’un tiers, d'autres interprétations des œuvres enregistrées en exécution du présent contrat pendant une durée maximum de 5 (cinq) années à compter de l'expiration de la durée d’exclusivité de fixation.</p>\n<p>Il garantit par ailleurs qu'il est libre de les enregistrer pour le compte du <strong>PRODUCTEUR</strong>.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Exclusivité - \"Exclusivité\"",
|
||||||
|
"name": "Contrat d'édition- Exclusivité",
|
||||||
|
"title": "Exclusivité",
|
||||||
|
"updated_at": "2023-03-14T21:21:04.467Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e56ac64aae93833c8f6d"),
|
||||||
|
"body": "<p>4.1 L'ARTISTE se déclare pleinement habilité à conclure le présent contrat.</p>\n<p>4.2 L'ARTISTE certifie n'être pas lié à ce jour par un quelconque contrat interdisant ou pouvant gêner la conclusion et/ou l’exécution des présentes. L’ARTISTE s’engage à ne pas autoriser pendant la Durée d’exclusivité de fixation, la commercialisation d’enregistrements fixés antérieurement à la date des présentes pour son propre<br>compte ou pour le compte d’un tiers et non encore publiés.</p>\n<p>4.3 L’ARTISTE garantit au PRODUCTEUR la jouissance et l’exploitation paisible de son nom patronymique et/ou de son pseudonyme.</p>\n<p>4.4 L'ARTISTE reconnaît que la déclaration ci-dessus engage son entière responsabilité, et qu'il sera responsable de toutes les pertes et du préjudice subi par le PRODUCTEUR du fait d’une fausse déclaration.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Garanties - \"Garanties\"",
|
||||||
|
"name": "Contrat d'exploitation - Garanties",
|
||||||
|
"title": "Garanties",
|
||||||
|
"updated_at": "2023-03-14T21:21:46.107Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e5b5c64aae93833c8f6f"),
|
||||||
|
"body": "<p>5.1 L'ARTISTE cède irrévocablement, pour le Monde, au PRODUCTEUR qui l’accepte, le droit exclusif, sans restriction ni réserve, de fixer ou autoriser la fixation, reproduire ou autoriser la reproduction, communiquer ou autoriser la communication au public, de toutes les prestations et interprétations fixées par l'ARTISTE pendant la durée du présent contrat, ainsi que tous les droits patrimoniaux présents et futurs s'y rattachant. </p>\n<p>Ces droits comprennent notamment :</p>\n<ul>\n<li>Le droit de fixer la prestation de l’ARTISTE</li>\n<li> Le droit exclusif de reproduire, et faire reproduire, fabriquer et faire fabriquer, publier et faire publier, mettre à la disposition du public par la vente, la location ou le prêt, sous toutes formes marques et étiquettes au choix du PRODUCTEUR et au prix qu’elle fixera, les prestations et interprétations de l’ARTISTE et plus généralement toute fixation de ces dernières, associées ou non à l’image, sous toutes formes connues ou à découvrir, à toutes fins, en entier ou par extraits, sur tous supports et configurations connus ou à découvrir.</li>\n<li> Le droit exclusif de communication au public et de mise à disposition du public en entier ou par extraits, par tous moyens connus ou à découvrir, notamment par l’intermédiaire de réseaux de transports de données avec ou sans fil (tels que le réseau internet, les réseaux de téléphonie mobile...), par projection, diffusion radioélectrique, satellite, télématique, réseaux informatiques et interactifs on-line ou off-line, câblodistribution, réseau télévisuel, des prestations et interprétations de l’ARTISTE (y compris non fixées), associées ou non à l’image, à toutes fins, sous toutes formes et configurations connues ou à découvrir</li>\n</ul>\n<p>5.2 L'ARTISTE reconnaît, sans restrictions ni réserves, que le PRODUCTEUR est seule propriétaire des biens meubles que constituent les bandes reproduisant les interprétations de l’ARTISTE enregistrées en application des présentes et l’ARTISTE reconnaît que le PRODUCTEUR est réputée seul producteur des phonogrammes, au sens du Code de la Propriété Intellectuelle.</p>\n<p>5.3 Le PRODUCTEUR exercera en conséquence les droits reconnus à l’ARTISTE par la loi comme ses droits propres.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Cession des droits - \"Cession des droits\"",
|
||||||
|
"name": "Contrat d'exploitation - Cession des droits",
|
||||||
|
"title": "Cession des droits",
|
||||||
|
"updated_at": "2023-03-14T21:23:01.572Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e5e2c64aae93833c8f70"),
|
||||||
|
"body": "<p>6.1 L'ARTISTE s'engage à soumettre au PRODUCTEUR avant toute entrée en studio les maquettes des œuvres qu'il souhaiterait enregistrer. Le PRODUCTEUR pourra également proposer des projets d’œuvres à interpréter.<br>Le choix des œuvres à enregistrer est ensuite effectué d'un commun accord entre l'ARTISTE et le PRODUCTEUR.</p>\n<p>Les séances en studio ne pourront débuter qu’après validation définitive par écrit par le PRODUCTEUR du projet d’enregistrement. Les dates des séances d'enregistrement seront déterminées par le PRODUCTEUR et l'ARTISTE d'un commun accord.</p>\n<p>6.2 L'ARTISTE s'engage à se présenter aux séances d'enregistrement prêt à enregistrer les titres<br>sélectionnés.</p>\n<p>6.3 L'ARTISTE s'engage à venir aux séances d'enregistrement prêt à réaliser l'enregistrement définitif, le PRODUCTEUR restant seul juge du résultat définitif.</p>\n<p>6.4 L'ARTISTE respectera le règlement intérieur des studios d'enregistrement.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Séances d’enregistrement en studio - \"Séances d’enregistrement en studio\"",
|
||||||
|
"name": "Contrat d'exploitation - Séances d’enregistrement en studio",
|
||||||
|
"title": "Séances d’enregistrement en studio",
|
||||||
|
"updated_at": "2023-03-14T21:23:46.930Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6410e63ac64aae93833c8f71"),
|
||||||
|
"body": "<p>7.1 Les Réalisateurs Artistiques, mixeurs et éventuels remixeurs et Beatmaker sont choisis par le PRODUCTEUR en concertation avec l'ARTISTE. La rémunération des Réalisateurs Artistiques, mixeurs ou remixeurs extérieurs est à la charge du PRODUCTEUR comme suit : <br>L’artiste ayant signé ce contrat recevra une rémunération au nombre d’écoutes des auditeurs sur les différentes plateformes de streaming : </p>\n<ul>\n<li>$250 par écoute pour le BEAT-MAKER</li>\n<li>$500 par écoute pour l’ARTISTE</li>\n</ul>\n<p>7.2 Le PRODUCTEUR se réserve le droit de rémunérer à titre exceptionnel une prime lors des concert/événements.</p>",
|
||||||
|
"created_at": "2023-03-14T14:07:16.735Z",
|
||||||
|
"label": "Contrat d'exploitation - Rémunération des réalisateurs artistiques, (re-)mixeurs, et beatmakers - \"Rémunération des réalisateurs artistiques, (re-)mixeurs, et beatmakers\"",
|
||||||
|
"name": "Contrat d'exploitation - Rémunération des réalisateurs artistiques, (re-)mixeurs, et beatmakers",
|
||||||
|
"title": "Rémunération des réalisateurs artistiques, (re-)mixeurs, et beatmakers",
|
||||||
|
"updated_at": "2023-03-14T21:25:14.798Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6413477adfeec019e7312278"),
|
||||||
|
"body": "<p dir=\"ltr\">Le CLIENT confie au RESTAURATEUR, qui accepte, la mission de fournir les prestations ci-après définies en vue de la restauration de son personnel, dans son établissement situé :</p>\n<p dir=\"ltr\"><strong>Eclipse Tower<br><br></strong>Le RESTAURATEUR fournira ses prestations en toute indépendance pour les quantités suivantes : 200 Menu Simple au prix de 45 $ le menu pour un total de 9000$</p>\n<p dir=\"ltr\">Menu Simple : </p>\n<div dir=\"ltr\" align=\"left\">\n<div dir=\"ltr\" align=\"left\">\n<table><colgroup></colgroup>\n<tbody>\n<tr>\n<td>\n<p dir=\"ltr\">Burger viande ou poisson</p>\n</td>\n</tr>\n<tr>\n<td>\n<p dir=\"ltr\">Soda</p>\n</td>\n</tr>\n</tbody>\n</table>\n</div>\n<strong id=\"docs-internal-guid-3e52c7d0-7fff-f92b-674f-d405d3884c38\"></strong></div>\n<p> </p>",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"label": "Contrat de Prestation de Service - \" Objet du contrat \"",
|
||||||
|
"name": "Contrat de Prestation de Service - Objet du contrat",
|
||||||
|
"title": "Objet du contrat",
|
||||||
|
"updated_at": "2023-03-16T16:44:42.446Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("641347f4dfeec019e7312279"),
|
||||||
|
"body": "<p dir=\"ltr\">Le présent contrat prend effet le %DATE_EFFET_CONTRAT%.</p>\n<p dir=\"ltr\">Il est conclu pour une durée d’une semaine renouvelable tacitement, chacune des parties ayant la possibilité d'en faire cesser l'effet à tout moment, à la condition expresse de prévenir l'autre partie par décision écrite et datée.</p>",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"label": "Contrat de Prestation de Service - Durée du contrat - \"Durée du contrat\"",
|
||||||
|
"name": "Contrat de Prestation de Service - Durée du contrat",
|
||||||
|
"title": "Durée du contrat",
|
||||||
|
"updated_at": "2023-03-16T16:46:44.703Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": ObjectId("6413482edfeec019e731227a"),
|
||||||
|
"body": "<p dir=\"ltr\">La rupture sera effective le premier jour de la semaine suivant sa notification écrite.</p>\n<ul>\n<li dir=\"ltr\" role=\"presentation\">En cas d’annulation à l’initiative du CLIENT, celui-ci s’engage à verser au RESTAURATEUR l’équivalent du prix d’une semaine de prestation sans contrepartie.</li>\n<li>En cas d’annulation à l’initiative du RESTAURATEUR, celui-ci s’engage à livrer gracieusement la commande de la semaine suivant l’annonce de la rupture.</li>\n</ul>",
|
||||||
|
"created_at": "2023-03-16T14:41:43.613Z",
|
||||||
|
"label": "Contrat de Prestation de Service - Rupture du contrat - \"Rupture du contrat\"",
|
||||||
|
"name": "Contrat de Prestation de Service - Rupture du contrat",
|
||||||
|
"title": "Rupture du contrat",
|
||||||
|
"updated_at": "2023-03-16T16:47:42.232Z",
|
||||||
|
"created_by": ObjectId("67f248427ad1b215ae859319"),
|
||||||
|
"updated_by": ObjectId("67f248427ad1b215ae859319")
|
||||||
|
}
|
||||||
|
])
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
fastapi
|
fastapi
|
||||||
|
fastapi-filter
|
||||||
|
fastapi-pagination
|
||||||
fastapi-users[beanie,oauth]
|
fastapi-users[beanie,oauth]
|
||||||
uvicorn
|
|
||||||
httpx-oauth
|
httpx-oauth
|
||||||
|
jinja2
|
||||||
|
uvicorn
|
||||||
|
weasyprint
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from firm.entity import entity_router
|
||||||
|
from firm.template import template_router
|
||||||
|
from firm.contract import contract_router
|
||||||
|
from firm.current_firm.routes import current_firm_router
|
||||||
|
|
||||||
|
|
||||||
|
firm_router = APIRouter(prefix="/{instance}/{firm}")
|
||||||
|
|
||||||
|
firm_router.include_router(current_firm_router, tags=["Current Firm"])
|
||||||
|
firm_router.include_router(entity_router, prefix="/entities", tags=["Entity"], )
|
||||||
|
firm_router.include_router(template_router, prefix="/templates", tags=["Template"], )
|
||||||
|
firm_router.include_router(contract_router, prefix="/contracts", )
|
||||||
|
|||||||
13
api/rpk-api/firm/contract/__init__.py
Normal file
13
api/rpk-api/firm/contract/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from firm.contract.routes_contract import contract_router as contract_subrouter
|
||||||
|
from firm.contract.routes_signature import signature_router
|
||||||
|
from firm.contract.routes_draft import draft_router
|
||||||
|
from firm.contract.print import print_router, preview_router
|
||||||
|
|
||||||
|
contract_router = APIRouter()
|
||||||
|
contract_router.include_router(draft_router, prefix="/drafts", tags=["Contract Draft"], )
|
||||||
|
contract_router.include_router(contract_subrouter, tags=["Contract"], )
|
||||||
|
contract_router.include_router(preview_router, prefix="/preview", )
|
||||||
|
contract_router.include_router(print_router, prefix="/print", )
|
||||||
|
contract_router.include_router(signature_router, prefix="/signature", tags=["Signature"], )
|
||||||
224
api/rpk-api/firm/contract/models.py
Normal file
224
api/rpk-api/firm/contract/models.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
from enum import Enum
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
from pydantic.json_schema import SkipJsonSchema
|
||||||
|
|
||||||
|
from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry, ForeignKey, \
|
||||||
|
CrudDocumentConfig
|
||||||
|
from firm.core.filter import Filter, FilterSchema
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
|
||||||
|
|
||||||
|
class ContractStatus(str, Enum):
|
||||||
|
published = 'published'
|
||||||
|
signed = 'signed'
|
||||||
|
printed = 'printed'
|
||||||
|
executed = 'executed'
|
||||||
|
canceled = 'canceled'
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDraftStatus(str, Enum):
|
||||||
|
in_progress = 'in_progress'
|
||||||
|
ready = 'ready'
|
||||||
|
published = 'published'
|
||||||
|
|
||||||
|
|
||||||
|
class DraftParty(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Partie')
|
||||||
|
|
||||||
|
entity_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Partie")
|
||||||
|
part: str = Field(title="Rôle")
|
||||||
|
representative_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Représentant")
|
||||||
|
|
||||||
|
entity: SkipJsonSchema[Entity] = Field(default=None, exclude=True, )
|
||||||
|
|
||||||
|
|
||||||
|
class Party(BaseModel):
|
||||||
|
entity: Entity
|
||||||
|
part: str
|
||||||
|
representative: Optional[Entity] = None
|
||||||
|
signature_uuid: str
|
||||||
|
signature_affixed: bool = False
|
||||||
|
signature_png: Optional[str] = None
|
||||||
|
|
||||||
|
class ContractProvisionType(Enum):
|
||||||
|
genuine = 'genuine'
|
||||||
|
template = 'template'
|
||||||
|
|
||||||
|
class ProvisionGenuine(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Clause personalisée')
|
||||||
|
|
||||||
|
type: Literal['genuine'] = ContractProvisionType.genuine
|
||||||
|
title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre")
|
||||||
|
body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps")
|
||||||
|
|
||||||
|
|
||||||
|
class ContractProvisionTemplateReference(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Template de clause')
|
||||||
|
|
||||||
|
type: Literal['template'] = ContractProvisionType.template
|
||||||
|
provision_template_id: PydanticObjectId = ForeignKey(
|
||||||
|
"templates/provisions",
|
||||||
|
"ProvisionTemplate",
|
||||||
|
displayed_fields=['title', 'body'],
|
||||||
|
props={"parametrized": True},
|
||||||
|
default=None,
|
||||||
|
title="Template de clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftProvision(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Clause')
|
||||||
|
|
||||||
|
provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type')
|
||||||
|
|
||||||
|
|
||||||
|
class Provision(BaseModel):
|
||||||
|
title: str = RichtextSingleline(title="Titre")
|
||||||
|
body: str = RichtextMultiline(title="Corps")
|
||||||
|
|
||||||
|
class ContractDraftUpdateStatus(BaseModel):
|
||||||
|
status: str = Field()
|
||||||
|
todo: List[str] = Field(default=[])
|
||||||
|
|
||||||
|
class ContractDraft(CrudDocument):
|
||||||
|
"""
|
||||||
|
Brouillon de contrat à remplir
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(title='Brouillon de contrat')
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = Field(title="Titre")
|
||||||
|
parties: List[DraftParty] = Field(title="Parties")
|
||||||
|
provisions: List[DraftProvision] = Field(title='Clauses')
|
||||||
|
variables: List[DictionaryEntry] = Field(default=[], title='Variables')
|
||||||
|
status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut")
|
||||||
|
todo: List[str] = Field(default=[], title="Reste à faire")
|
||||||
|
|
||||||
|
async def check_is_ready(self, db):
|
||||||
|
if self.status == ContractDraftStatus.published:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.todo = []
|
||||||
|
if len(self.parties) < 2:
|
||||||
|
self.todo.append('Contract must have at least two parties')
|
||||||
|
if len(self.provisions) < 1:
|
||||||
|
self.todo.append('Contract must have at least one provision')
|
||||||
|
|
||||||
|
for p in self.parties:
|
||||||
|
if not p.entity_id:
|
||||||
|
self.todo.append('All parties must have an associated entity`')
|
||||||
|
|
||||||
|
for p in self.provisions:
|
||||||
|
if p.provision.type == "genuine" and not (p.provision.title and p.provision.body):
|
||||||
|
self.todo.append('Empty genuine provision')
|
||||||
|
elif p.provision.type == "template" and not p.provision.provision_template_id:
|
||||||
|
self.todo.append('Empty template provision')
|
||||||
|
|
||||||
|
for v in self.variables:
|
||||||
|
if not (v.key and v.value):
|
||||||
|
self.todo.append(f'Empty variable: {v.key}')
|
||||||
|
|
||||||
|
if self.todo:
|
||||||
|
self.status = ContractDraftStatus.in_progress
|
||||||
|
else:
|
||||||
|
self.status = ContractDraftStatus.ready
|
||||||
|
|
||||||
|
await self.update(db, self, ContractDraftUpdateStatus(status=self.status, todo=self.todo))
|
||||||
|
|
||||||
|
async def update_status(self, db, status):
|
||||||
|
update = ContractDraftUpdateStatus(status=status)
|
||||||
|
await self.update(db, self, update)
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return f"{self.name} - {self.title}"
|
||||||
|
|
||||||
|
class Contract(CrudDocument):
|
||||||
|
"""
|
||||||
|
Contrat publié. Les contrats ne peuvent pas être modifiés.
|
||||||
|
Ils peuvent seulement être signés par les parties et imprimés par l'avocat
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(title='Contrat')
|
||||||
|
document_config = CrudDocumentConfig(
|
||||||
|
indexes=["parties.signature_uuid"],
|
||||||
|
)
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = Field(title="Titre")
|
||||||
|
parties: List[Party] = Field(title="Parties")
|
||||||
|
provisions: List[Provision] = Field(
|
||||||
|
props={"items_per_row": "1", "numbered": True},
|
||||||
|
title='Clauses'
|
||||||
|
)
|
||||||
|
status: ContractStatus = Field(default=ContractStatus.published, title="Statut")
|
||||||
|
lawyer: Entity = Field(title="Avocat en charge")
|
||||||
|
location: str = Field(title="Lieu")
|
||||||
|
date: datetime.date = Field(title="Date")
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
contract_label = self.title
|
||||||
|
for p in self.parties:
|
||||||
|
contract_label = f"{contract_label} - {p.entity.label}"
|
||||||
|
|
||||||
|
contract_label = f"{contract_label} - {self.date.strftime('%m/%d/%Y')}"
|
||||||
|
return contract_label
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def find_by_signature_id(cls, db, signature_id: UUID):
|
||||||
|
request = {'parties': {"$elemMatch": {"signature_uuid": str(signature_id) }}}
|
||||||
|
value = await cls._get_collection(db).find_one(request)
|
||||||
|
return cls.model_validate(value) if value else None
|
||||||
|
|
||||||
|
def get_signature(self, signature_id: str):
|
||||||
|
for p in self.parties:
|
||||||
|
if p.signature_uuid == str(signature_id):
|
||||||
|
return p
|
||||||
|
|
||||||
|
def get_signature_index(self, signature_id: str):
|
||||||
|
for i, p in enumerate(self.parties):
|
||||||
|
if p.signature_uuid == str(signature_id):
|
||||||
|
return i
|
||||||
|
|
||||||
|
def is_signed(self):
|
||||||
|
for p in self.parties:
|
||||||
|
if not p.signature_affixed:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def affix_signature(self, db, signature_index):
|
||||||
|
update_query = {"$set": {
|
||||||
|
f'parties.{signature_index}.signature_affixed': True
|
||||||
|
}}
|
||||||
|
|
||||||
|
self.parties[signature_index].signature_affixed = True
|
||||||
|
if self.is_signed():
|
||||||
|
update_query["$set"]['status'] = 'signed'
|
||||||
|
|
||||||
|
await self._get_collection(db).update_one({"_id": self.id}, update_query)
|
||||||
|
return await self.get(db, self.id)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_variables_in_value(variables, value: str):
|
||||||
|
for v in variables:
|
||||||
|
if v.value:
|
||||||
|
value = value.replace('%{}%'.format(v.key), v.value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDraftFilters(FilterSchema):
|
||||||
|
status__in: Optional[list[str]] = None
|
||||||
|
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = ContractDraft
|
||||||
|
search_model_fields = ["label"]
|
||||||
|
|
||||||
|
class ContractFilters(FilterSchema):
|
||||||
|
status__in: Optional[list[str]] = None
|
||||||
|
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = Contract
|
||||||
|
search_model_fields = ["label"]
|
||||||
161
api/rpk-api/firm/contract/print/__init__.py
Normal file
161
api/rpk-api/firm/contract/print/__init__.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import datetime
|
||||||
|
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
|
||||||
|
|
||||||
|
from weasyprint import HTML, CSS
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from firm.core.depends import get_tenant_registry
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
from firm.template.models import ProvisionTemplate
|
||||||
|
from firm.contract.models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
|
||||||
|
|
||||||
|
|
||||||
|
async def build_model(db, model):
|
||||||
|
parties = []
|
||||||
|
for p in model.parties:
|
||||||
|
party = {
|
||||||
|
"entity": await Entity.get(db, p.entity_id),
|
||||||
|
"part": p.part
|
||||||
|
}
|
||||||
|
if p.representative_id:
|
||||||
|
party['representative'] = await Entity.get(db, p.representative_id)
|
||||||
|
|
||||||
|
parties.append(party)
|
||||||
|
|
||||||
|
provisions = []
|
||||||
|
for p in model.provisions:
|
||||||
|
if p.provision.type == "template":
|
||||||
|
provision = await ProvisionTemplate.get(db, p.provision.provision_template_id)
|
||||||
|
else:
|
||||||
|
provision = p.provision
|
||||||
|
|
||||||
|
provision.title = replace_variables_in_value(model.variables, provision.title)
|
||||||
|
provision.body = replace_variables_in_value(model.variables, provision.body)
|
||||||
|
provisions.append(provision)
|
||||||
|
|
||||||
|
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_dict
|
||||||
|
|
||||||
|
|
||||||
|
BASE_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(BASE_PATH / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
async def render_print(root_url, contract):
|
||||||
|
template = templates.get_template("print.html")
|
||||||
|
return template.render({
|
||||||
|
"contract": contract,
|
||||||
|
"root_url": root_url
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def render_css(root_url, contract):
|
||||||
|
template = templates.get_template("styles.css")
|
||||||
|
return template.render({
|
||||||
|
"contract": contract,
|
||||||
|
"root_url": root_url
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_signature_png(filepath):
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
b_content = f.read()
|
||||||
|
base64_utf8_str = base64.b64encode(b_content).decode('utf-8')
|
||||||
|
ext = filepath.split('.')[-1]
|
||||||
|
return f'data:image/{ext};base64,{base64_utf8_str}'
|
||||||
|
|
||||||
|
|
||||||
|
preview_router = APIRouter()
|
||||||
|
@preview_router.get("/draft/{draft_id}", response_class=HTMLResponse, tags=["Contract Draft"])
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@preview_router.get("/signature/{signature_id}", response_class=HTMLResponse, tags=["Signature"])
|
||||||
|
async def preview_contract_by_signature(signature_id: UUID, reg=Depends(get_tenant_registry)) -> str:
|
||||||
|
contract = await Contract.find_by_signature_id(reg.db, signature_id)
|
||||||
|
for p in contract.parties:
|
||||||
|
if p.signature_affixed:
|
||||||
|
p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png')
|
||||||
|
|
||||||
|
return await render_print('', contract)
|
||||||
|
|
||||||
|
|
||||||
|
@preview_router.get("/{contract_id}", response_class=HTMLResponse, tags=["Contract"])
|
||||||
|
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:
|
||||||
|
p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png')
|
||||||
|
|
||||||
|
return await render_print('', contract)
|
||||||
|
|
||||||
|
|
||||||
|
print_router = APIRouter()
|
||||||
|
@print_router.get("/pdf/{contract_id}", response_class=FileResponse, tags=["Contract"])
|
||||||
|
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):
|
||||||
|
if contract.status != ContractStatus.signed:
|
||||||
|
raise HTTPException(status_code=400, detail="Contract is not in a printable state")
|
||||||
|
|
||||||
|
for p in contract.parties:
|
||||||
|
signature_path = f'media/signatures/{p.signature_uuid}.png'
|
||||||
|
p.signature_png = retrieve_signature_png(signature_path)
|
||||||
|
# os.remove(signature_path)
|
||||||
|
|
||||||
|
font_config = FontConfiguration()
|
||||||
|
html = HTML(string=await render_print('http://nginx', contract))
|
||||||
|
css = CSS(string=await render_css('http://nginx', contract), font_config=font_config)
|
||||||
|
|
||||||
|
html.write_pdf(contract_path, stylesheets=[css], font_config=font_config)
|
||||||
|
|
||||||
|
await contract.update_status(reg.db, 'printed')
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
contract_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=contract.label)
|
||||||
|
|
||||||
|
|
||||||
|
@print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse, tags=["Signature"])
|
||||||
|
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")
|
||||||
|
|
||||||
|
signatory = signature.representative.label if signature.representative else signature.entity.label
|
||||||
|
|
||||||
|
return template.render({
|
||||||
|
"signatory": signatory,
|
||||||
|
"title": contract.label,
|
||||||
|
"origin_url": f"{request.url.scheme}://{request.url.hostname}"
|
||||||
|
})
|
||||||
10
api/rpk-api/firm/contract/print/templates/opengraph.html
Normal file
10
api/rpk-api/firm/contract/print/templates/opengraph.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta property="og:title" content="{{ title }}" />
|
||||||
|
<meta property="og:description" content="Cette page est à la destination exclusive de {{ signatory }}
|
||||||
|
Si vous n'êtes pas {{ signatory }}, veuillez fermer cette page immédiatement et surpprimer tous les liens en votre possession menant vers celle-ci.
|
||||||
|
En vous maintenant et/ou en interagissant avec cette page, vous enfreignez l'article L.229 du code pénal de l'Etat de San Andreas pour usurpation d'identité et vous vous exposez ainsi à une amende de 20 000$ ainsi qu'à des poursuites civiles.
|
||||||
|
Le cabinet Cooper, Hillman & Toshi LLC" />
|
||||||
|
<meta property="og:image" content="{{ origin_url }}/assets/logo.png" />
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
76
api/rpk-api/firm/contract/print/templates/print.html
Normal file
76
api/rpk-api/firm/contract/print/templates/print.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
{% include 'styles.css' %}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frontpage">
|
||||||
|
<div id="front-page-header">
|
||||||
|
<table><tr>
|
||||||
|
<td><img id="top-logo" src="{{ root_url }}/assets/logotransparent.png" alt="Cooper, Hillman & Toshi logo"></td>
|
||||||
|
<td id="office-info">Cooper, Hillman & Toshi LLP<br />6834 Innocence Boulevard<br />LOS SANTOS - SA<br /><a href="#">consulting@cht.law.com</a></td>
|
||||||
|
</tr></table>
|
||||||
|
<h1>{{ contract.title|upper }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="intro">
|
||||||
|
<h2>Introduction</h2>
|
||||||
|
<p>Le {{ contract.date.strftime('%d/%m/%Y') }} à {{ contract.location}}</p>
|
||||||
|
<p>Entre les soussignés :</p>
|
||||||
|
{% for party in contract.parties %}
|
||||||
|
<div class="party">
|
||||||
|
{% if not loop.first %}
|
||||||
|
<p>ET</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
{% if party.entity.entity_data.type == "corporation" %}
|
||||||
|
{{ party.entity.entity_data.title }} société de {{ party.entity.entity_data.activity }} enregistrée auprès du gouvernement de San Andreas et domiciliée au {{ party.entity.address }}{% if party.representative %}, représentée par {{ party.representative.entity_data.firstname }} {{ party.representative.entity_data.middlenames }} {{ party.representative.entity_data.lastname }}{% endif %}
|
||||||
|
{% elif party.entity.entity_data.type == "individual" %}
|
||||||
|
{{ party.entity.entity_data.firstname }} {{ party.entity.entity_data.middlenames }} {{ party.entity.entity_data.lastname }}
|
||||||
|
{% if party.entity.entity_data.day_of_birth %} né le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if party.entity.entity_data.place_of_birth %} à {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %}
|
||||||
|
{% if party.entity.address %} résidant à {{ party.entity.address }}, {% endif %}
|
||||||
|
{% elif party.entity.entity_data.type == "institution" %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p>Ci-après dénommé <strong>{{ party.part|safe }}</strong></p>
|
||||||
|
{% if loop.first %}
|
||||||
|
<p class="part">d'une part</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<p class="part">d'autre part</p>
|
||||||
|
<p>Sous la supervision légale de Maître <strong>{{ contract.lawyer.entity_data.firstname }} {{ contract.lawyer.entity_data.lastname }}</strong></p>
|
||||||
|
<p>Il a été convenu l'exécution des prestations ci-dessous, conformément aux conditions générales et particulières ci-après:</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Conditions générales & particulières</h2>
|
||||||
|
|
||||||
|
{% for provision in contract.provisions %}
|
||||||
|
<div class="provision">
|
||||||
|
<h3>Article {{loop.index}} - {{ provision.title|safe }}</h3>
|
||||||
|
<p>{{ provision.body|safe }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<hr/>
|
||||||
|
<p>À {{ contract.location }} le {{ contract.date.strftime('%d/%m/%Y') }}</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 %}
|
||||||
|
<td>
|
||||||
|
{{ party.part|safe }}:<br/>
|
||||||
|
{% if party.signature_png %}
|
||||||
|
<img src="{{ party.signature_png }}" />
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
147
api/rpk-api/firm/contract/print/templates/styles.css
Normal file
147
api/rpk-api/firm/contract/print/templates/styles.css
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Century Schoolbook';
|
||||||
|
src: url('{{ root_url }}/assets/century-schoolbook/CenturySchoolbookRegular.ttf');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Century Schoolbook";
|
||||||
|
src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBold.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Century Schoolbook";
|
||||||
|
src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookItalic.ttf");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Century Schoolbook";
|
||||||
|
src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBoldItalic.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page{
|
||||||
|
size: a4 portrait;
|
||||||
|
margin: 2cm 2cm 2cm 2cm;
|
||||||
|
counter-increment: page;
|
||||||
|
@bottom-center {
|
||||||
|
content: "© Cooper, Hillman & Toshi LLC - {{ contract.name }} - Page " counter(page) "/" counter(pages);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
background: url('{{ root_url }}/assets/watermark.png') no-repeat;
|
||||||
|
background-size:contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page:first {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size:1em;
|
||||||
|
width:17cm;
|
||||||
|
font-family: 'Century Schoolbook';
|
||||||
|
}
|
||||||
|
|
||||||
|
#front-page-header {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
#front-page-header table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top-logo {
|
||||||
|
width: 5cm;
|
||||||
|
width: 5cm;
|
||||||
|
border: solid 1px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
#office-info {
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2.6em;
|
||||||
|
padding: 13px 0;
|
||||||
|
margin: 50px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
background: lightgrey;
|
||||||
|
font-size: 1.6em;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h3 {
|
||||||
|
margin-top: 55px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.5em;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
text-indent: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content td p {
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provision {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures td {
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
height: 3cm;
|
||||||
|
}
|
||||||
69
api/rpk-api/firm/contract/routes_contract.py
Normal file
69
api/rpk-api/firm/contract/routes_contract.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import uuid
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
|
||||||
|
from firm.core.routes import get_crud_router
|
||||||
|
from firm.core.depends import get_authed_tenant_registry
|
||||||
|
|
||||||
|
from firm.contract.models import Contract, ContractDraft, ContractDraftStatus, replace_variables_in_value, ContractFilters
|
||||||
|
from firm.contract.schemas import ContractCreate, ContractRead, ContractUpdate, ContractInit
|
||||||
|
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
from firm.template.models import ProvisionTemplate
|
||||||
|
|
||||||
|
|
||||||
|
contract_router = get_crud_router(Contract, ContractCreate, ContractRead, ContractUpdate, ContractFilters)
|
||||||
|
del(contract_router.routes[4]) #delete
|
||||||
|
del(contract_router.routes[3]) #update
|
||||||
|
del(contract_router.routes[1]) #create
|
||||||
|
|
||||||
|
@contract_router.post("/", response_description="Contract Successfully created")
|
||||||
|
async def create(schema: ContractCreate, reg=Depends(get_authed_tenant_registry)) -> ContractRead:
|
||||||
|
await schema.validate_foreign_key(reg.db)
|
||||||
|
|
||||||
|
draft = await ContractDraft.get(reg.db, schema.draft_id)
|
||||||
|
if not draft:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Contract draft not found!")
|
||||||
|
|
||||||
|
for v in draft.variables:
|
||||||
|
if not v.key or not v.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Variable {} is invalid".format(v))
|
||||||
|
|
||||||
|
contract_dict = schema.model_dump()
|
||||||
|
del(contract_dict['draft_id'])
|
||||||
|
|
||||||
|
contract_dict['lawyer'] = reg.partner.model_dump()
|
||||||
|
|
||||||
|
contract_dict['name'] = draft.name
|
||||||
|
contract_dict['title'] = draft.title
|
||||||
|
parties = []
|
||||||
|
for p in draft.parties:
|
||||||
|
parties.append({
|
||||||
|
'entity': await Entity.get(reg.db, p.entity_id),
|
||||||
|
'part': p.part,
|
||||||
|
'representative': await Entity.get(reg.db, p.representative_id) if p.representative_id else None,
|
||||||
|
'signature_uuid': str(uuid.uuid4())
|
||||||
|
})
|
||||||
|
|
||||||
|
contract_dict['parties'] = parties
|
||||||
|
|
||||||
|
provisions = []
|
||||||
|
for p in draft.provisions:
|
||||||
|
p = p.provision
|
||||||
|
provision = await ProvisionTemplate.get(reg.db, p.provision_template_id) if p.type == "template" \
|
||||||
|
else p
|
||||||
|
|
||||||
|
provisions.append({
|
||||||
|
'title': replace_variables_in_value(draft.variables, provision.title),
|
||||||
|
'body': replace_variables_in_value(draft.variables, provision.body)
|
||||||
|
})
|
||||||
|
|
||||||
|
contract_dict['provisions'] = provisions
|
||||||
|
|
||||||
|
record = await Contract.create(reg.db, ContractInit(**contract_dict))
|
||||||
|
await draft.update_status(reg.db, ContractDraftStatus.published)
|
||||||
|
|
||||||
|
return ContractRead.from_model(record)
|
||||||
|
|
||||||
|
@contract_router.put("/{record_id}", response_description="")
|
||||||
|
async def update(record_id: str, contract_form: ContractUpdate, reg=Depends(get_authed_tenant_registry)) -> ContractRead:
|
||||||
|
raise HTTPException(status_code=400, detail="No modification on contract")
|
||||||
42
api/rpk-api/firm/contract/routes_draft.py
Normal file
42
api/rpk-api/firm/contract/routes_draft.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from beanie import PydanticObjectId
|
||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
|
||||||
|
from firm.core.routes import get_crud_router
|
||||||
|
from firm.core.depends import get_authed_tenant_registry
|
||||||
|
|
||||||
|
from firm.contract.models import ContractDraft, ContractDraftStatus, ContractDraftFilters
|
||||||
|
from firm.contract.schemas import ContractDraftCreate, ContractDraftRead, ContractDraftUpdate
|
||||||
|
|
||||||
|
draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate, ContractDraftFilters)
|
||||||
|
|
||||||
|
del(draft_router.routes[3]) #update route
|
||||||
|
del(draft_router.routes[1]) #post route
|
||||||
|
|
||||||
|
|
||||||
|
@draft_router.post("/", response_description="Contract Draft added to the database")
|
||||||
|
async def create(schema: ContractDraftCreate, reg=Depends(get_authed_tenant_registry)) -> ContractDraftRead:
|
||||||
|
await schema.validate_foreign_key(reg.db)
|
||||||
|
record = await ContractDraft.create(reg.db, schema)
|
||||||
|
await record.check_is_ready(reg.db)
|
||||||
|
|
||||||
|
return ContractDraftRead.from_model(record)
|
||||||
|
|
||||||
|
|
||||||
|
@draft_router.put("/{record_id}", response_description="Contract Draft record updated")
|
||||||
|
async def update(record_id: PydanticObjectId, schema: ContractDraftUpdate, reg=Depends(get_authed_tenant_registry)) -> ContractDraftRead:
|
||||||
|
record = await ContractDraft.get(reg.db, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Contract Draft record not found!"
|
||||||
|
)
|
||||||
|
if record.status == ContractDraftStatus.published:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Contract Draft has already been published"
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await ContractDraft.update(reg.db, record, schema)
|
||||||
|
await record.check_is_ready(reg.db)
|
||||||
|
|
||||||
|
return ContractDraftRead.from_model(record)
|
||||||
35
api/rpk-api/firm/contract/routes_signature.py
Normal file
35
api/rpk-api/firm/contract/routes_signature.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import Depends, HTTPException, File, UploadFile, APIRouter
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from firm.contract.models import Contract, Party
|
||||||
|
from firm.core.depends import get_tenant_registry
|
||||||
|
|
||||||
|
|
||||||
|
signature_router = APIRouter()
|
||||||
|
|
||||||
|
@signature_router.get("/{signature_id}", response_description="")
|
||||||
|
async def get_signature(signature_id: UUID, reg=Depends(get_tenant_registry)) -> Party:
|
||||||
|
contract = await Contract.find_by_signature_id(reg.db, signature_id)
|
||||||
|
signature = contract.get_signature(signature_id)
|
||||||
|
return signature
|
||||||
|
|
||||||
|
@signature_router.post("/{signature_id}", response_description="")
|
||||||
|
async def affix_signature(signature_id: UUID, signature_file: UploadFile = File(...), reg=Depends(get_tenant_registry)) -> bool:
|
||||||
|
contract = await Contract.find_by_signature_id(reg.db, signature_id)
|
||||||
|
|
||||||
|
if not contract:
|
||||||
|
raise HTTPException(status_code=404, detail="Contract record not found!")
|
||||||
|
|
||||||
|
signature_index = contract.get_signature_index(signature_id)
|
||||||
|
signature = contract.parties[signature_index]
|
||||||
|
|
||||||
|
if signature.signature_affixed:
|
||||||
|
raise HTTPException(status_code=400, detail="Signature already affixed")
|
||||||
|
|
||||||
|
with open(f'media/signatures/{signature_id}.png', "wb") as buffer:
|
||||||
|
shutil.copyfileobj(signature_file.file, buffer)
|
||||||
|
|
||||||
|
await contract.affix_signature(reg.db, signature_index)
|
||||||
|
return True
|
||||||
84
api/rpk-api/firm/contract/schemas.py
Normal file
84
api/rpk-api/firm/contract/schemas.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
from firm.contract.models import ContractDraft, DraftProvision, DraftParty, Contract
|
||||||
|
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
from firm.core.schemas import Writer, Reader
|
||||||
|
from firm.core.models import DictionaryEntry, ForeignKey
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDraftRead(Reader, ContractDraft):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDraftCreate(Writer):
|
||||||
|
name: str = Field(title='Nom')
|
||||||
|
title: str = Field(title='Titre')
|
||||||
|
parties: List[DraftParty] = Field(title='Parties')
|
||||||
|
provisions: List[DraftProvision] = Field(
|
||||||
|
props={"items_per_row": "1", "numbered": True},
|
||||||
|
title='Clauses'
|
||||||
|
)
|
||||||
|
variables: List[DictionaryEntry] = Field(
|
||||||
|
default=[],
|
||||||
|
props={"display": "dictionary"},
|
||||||
|
title='Variables'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_foreign_key(self, db):
|
||||||
|
for p in self.parties:
|
||||||
|
if p.entity_id:
|
||||||
|
p.entity = await Entity.get(db, p.entity_id)
|
||||||
|
if p.entity is None:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDraftUpdate(ContractDraftCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ForeignEntityRead(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Avocat')
|
||||||
|
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class PartyRead(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Partie')
|
||||||
|
|
||||||
|
signature_affixed: bool = Field(title='Signature apposée?')
|
||||||
|
signature_uuid: str = Field(props={"display": "signature-link"}, title="Lien vers signature")
|
||||||
|
part: str = Field(title='Rôle')
|
||||||
|
entity: ForeignEntityRead = Field(title='Client')
|
||||||
|
|
||||||
|
|
||||||
|
class ContractRead(Reader, Contract):
|
||||||
|
model_config = ConfigDict(title='Contrat')
|
||||||
|
|
||||||
|
parties: List[PartyRead] = Field(
|
||||||
|
props={"items_per_row": "2"},
|
||||||
|
title='Parties'
|
||||||
|
)
|
||||||
|
lawyer: ForeignEntityRead
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCreate(Writer):
|
||||||
|
date: datetime.date
|
||||||
|
location: str
|
||||||
|
draft_id: PydanticObjectId = ForeignKey(resource="contracts/drafts", schema="ContractDraft")
|
||||||
|
|
||||||
|
class ContractInit(BaseModel):
|
||||||
|
date: datetime.date
|
||||||
|
location: str
|
||||||
|
lawyer: dict
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
parties: List[dict]
|
||||||
|
provisions: List[dict]
|
||||||
|
|
||||||
|
class ContractUpdate(BaseModel):
|
||||||
|
pass
|
||||||
0
api/rpk-api/firm/core/__init__.py
Normal file
0
api/rpk-api/firm/core/__init__.py
Normal file
70
api/rpk-api/firm/core/depends.py
Normal file
70
api/rpk-api/firm/core/depends.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
|
||||||
|
from hub.auth import get_current_user
|
||||||
|
|
||||||
|
from firm.current_firm import CurrentFirm, Partner
|
||||||
|
from firm.db import get_db_client
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
user = None
|
||||||
|
partner = None
|
||||||
|
|
||||||
|
def __init__(self, db_client, instance, firm):
|
||||||
|
self.db = db_client[f"tenant_{instance}_{firm}"]
|
||||||
|
self.instance = instance
|
||||||
|
self.firm = firm
|
||||||
|
|
||||||
|
self.current_firm = CurrentFirm.get_current(self.db)
|
||||||
|
|
||||||
|
def check_user(self, user):
|
||||||
|
for firm in user.firms:
|
||||||
|
if firm.instance == self.instance and firm.firm == self.firm:
|
||||||
|
return True
|
||||||
|
raise PermissionError
|
||||||
|
|
||||||
|
async def set_user(self, user):
|
||||||
|
self.check_user(user)
|
||||||
|
|
||||||
|
partner = await Partner.get_by_user_id(self.db, user.id)
|
||||||
|
partner_entity = await Entity.get(self.db, partner.entity_id)
|
||||||
|
self.user = user
|
||||||
|
self.partner = partner_entity
|
||||||
|
self.db.partner = partner_entity
|
||||||
|
return
|
||||||
|
|
||||||
|
raise PermissionError
|
||||||
|
|
||||||
|
async def get_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client)) -> Registry:
|
||||||
|
registry = Registry(db_client, instance, firm)
|
||||||
|
if await registry.current_firm is None:
|
||||||
|
raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
|
||||||
|
|
||||||
|
return registry
|
||||||
|
|
||||||
|
async def get_authed_tenant_registry(instance: str, firm: str, db_client=Depends(get_db_client), user=Depends(get_current_user)) -> Registry:
|
||||||
|
registry = Registry(db_client, instance, firm)
|
||||||
|
try:
|
||||||
|
registry.check_user(user)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
|
||||||
|
|
||||||
|
if await registry.current_firm is None:
|
||||||
|
raise HTTPException(status_code=405, detail=f"Firm needs to be initialized first")
|
||||||
|
|
||||||
|
await registry.set_user(user)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
async def get_uninitialized_registry(instance: str, firm: str, db_client=Depends(get_db_client), user=Depends(get_current_user)) -> Registry:
|
||||||
|
registry = Registry(db_client, instance, firm)
|
||||||
|
try:
|
||||||
|
registry.check_user(user)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=404, detail="This firm doesn't exist or you are not allowed to access it.")
|
||||||
|
|
||||||
|
if await registry.current_firm is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Firm configuration already exists")
|
||||||
|
|
||||||
|
await registry.set_user(user)
|
||||||
|
return registry
|
||||||
165
api/rpk-api/firm/core/filter.py
Normal file
165
api/rpk-api/firm/core/filter.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pydantic import ValidationInfo, field_validator
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from fastapi_filter.base.filter import BaseFilterModel
|
||||||
|
|
||||||
|
_odm_operator_transformer: dict[str, Callable[[Optional[str]], Optional[dict[str, Any]]]] = {
|
||||||
|
"neq": lambda value: {"$ne": value},
|
||||||
|
"gt": lambda value: {"$gt": value},
|
||||||
|
"gte": lambda value: {"$gte": value},
|
||||||
|
"in": lambda value: {"$in": value},
|
||||||
|
"isnull": lambda value: None if value else {"$ne": None},
|
||||||
|
"lt": lambda value: {"$lt": value},
|
||||||
|
"lte": lambda value: {"$lte": value},
|
||||||
|
"not": lambda value: {"$ne": value},
|
||||||
|
"ne": lambda value: {"$ne": value},
|
||||||
|
"not_in": lambda value: {"$nin": value},
|
||||||
|
"nin": lambda value: {"$nin": value},
|
||||||
|
"like": lambda value: {"$regex": f".*{value}.*"},
|
||||||
|
"ilike": lambda value: {"$regex": f".*{value}.*", "$options": "i"},
|
||||||
|
"exists": lambda value: {"$exists": value},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Filter(BaseFilterModel):
|
||||||
|
def sort(self):
|
||||||
|
if not self.ordering_values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sort = {}
|
||||||
|
for column in self.ordering_values:
|
||||||
|
direction = 1
|
||||||
|
if column[0] in ["+", "-"]:
|
||||||
|
if column[0] == "-":
|
||||||
|
direction = -1
|
||||||
|
column = column[1:]
|
||||||
|
|
||||||
|
sort[column] = direction
|
||||||
|
|
||||||
|
return sort
|
||||||
|
|
||||||
|
@field_validator("*", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def split_str(
|
||||||
|
cls: type["BaseFilterModel"], value: Optional[str], field: ValidationInfo
|
||||||
|
) -> Optional[Union[list[str], str]]:
|
||||||
|
if (
|
||||||
|
field.field_name is not None
|
||||||
|
and (
|
||||||
|
field.field_name == cls.Constants.ordering_field_name
|
||||||
|
or field.field_name.endswith("__in")
|
||||||
|
or field.field_name.endswith("__nin")
|
||||||
|
)
|
||||||
|
and isinstance(value, str)
|
||||||
|
):
|
||||||
|
if not value:
|
||||||
|
# Empty string should return [] not ['']
|
||||||
|
return []
|
||||||
|
return list(value.split(","))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _get_filter_conditions(self, nesting_depth: int = 1) -> list[tuple[Mapping[str, Any], Mapping[str, Any]]]:
|
||||||
|
filter_conditions: list[tuple[Mapping[str, Any], Mapping[str, Any]]] = []
|
||||||
|
for field_name, value in self.filtering_fields:
|
||||||
|
field_value = getattr(self, field_name)
|
||||||
|
if isinstance(field_value, Filter):
|
||||||
|
if not field_value.model_dump(exclude_none=True, exclude_unset=True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filter_conditions.append(
|
||||||
|
(
|
||||||
|
{field_name: _odm_operator_transformer["neq"](None)},
|
||||||
|
{"fetch_links": True, "nesting_depth": nesting_depth},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for part, part_options in field_value._get_filter_conditions(nesting_depth=nesting_depth + 1): # noqa: SLF001
|
||||||
|
for sub_field_name, sub_value in part.items():
|
||||||
|
filter_conditions.append(
|
||||||
|
(
|
||||||
|
{f"{field_name}.{sub_field_name}": sub_value},
|
||||||
|
{"fetch_links": True, "nesting_depth": nesting_depth, **part_options},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif "__" in field_name:
|
||||||
|
stripped_field_name, operator = field_name.split("__")
|
||||||
|
search_criteria = _odm_operator_transformer[operator](value)
|
||||||
|
filter_conditions.append(({stripped_field_name: search_criteria}, {}))
|
||||||
|
elif field_name == self.Constants.search_field_name and hasattr(self.Constants, "search_model_fields"):
|
||||||
|
search_conditions = [
|
||||||
|
{search_field: _odm_operator_transformer["ilike"](value)}
|
||||||
|
for search_field in self.Constants.search_model_fields
|
||||||
|
]
|
||||||
|
filter_conditions.append(({"$or": search_conditions}, {}))
|
||||||
|
else:
|
||||||
|
filter_conditions.append(({field_name: value}, {}))
|
||||||
|
|
||||||
|
return filter_conditions
|
||||||
|
|
||||||
|
def filter(self, query):
|
||||||
|
data = self._get_filter_conditions()
|
||||||
|
for filter_condition, filter_kwargs in data:
|
||||||
|
for field_name, value in filter_condition.items():
|
||||||
|
if field_name in query:
|
||||||
|
query[field_name] = query[field_name] | value
|
||||||
|
else:
|
||||||
|
query[field_name] = value
|
||||||
|
return query
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def field_exists(model, field_path: str) -> bool:
|
||||||
|
if "." in field_path:
|
||||||
|
[root, field] = field_path.split(".", 1)
|
||||||
|
return hasattr(model, "model_fields") and root in model.model_fields \
|
||||||
|
and model.model_fields[root].discriminator == field
|
||||||
|
|
||||||
|
return hasattr(model, field_path) or (hasattr(model, "model_fields") and field_path in model.model_fields)
|
||||||
|
|
||||||
|
@field_validator("*", mode="before", check_fields=False)
|
||||||
|
def validate_order_by(cls, value, field: ValidationInfo):
|
||||||
|
if field.field_name != cls.Constants.ordering_field_name:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
field_name_usages = defaultdict(list)
|
||||||
|
duplicated_field_names = set()
|
||||||
|
|
||||||
|
for field_name_with_direction in value:
|
||||||
|
field_name = field_name_with_direction.replace("-", "").replace("+", "")
|
||||||
|
|
||||||
|
if not cls.field_exists(cls.Constants.model, field_name):
|
||||||
|
raise ValueError(f"{field_name} is not a valid ordering field.")
|
||||||
|
|
||||||
|
field_name_usages[field_name].append(field_name_with_direction)
|
||||||
|
if len(field_name_usages[field_name]) > 1:
|
||||||
|
duplicated_field_names.add(field_name)
|
||||||
|
|
||||||
|
if duplicated_field_names:
|
||||||
|
ambiguous_field_names = ", ".join(
|
||||||
|
[
|
||||||
|
field_name_with_direction
|
||||||
|
for field_name in sorted(duplicated_field_names)
|
||||||
|
for field_name_with_direction in field_name_usages[field_name]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"Field names can appear at most once for {cls.Constants.ordering_field_name}. "
|
||||||
|
f"The following was ambiguous: {ambiguous_field_names}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
class FilterSchema(Filter):
|
||||||
|
label__ilike: Optional[str] = None
|
||||||
|
search: Optional[str] = None
|
||||||
|
order_by: Optional[list[str]] = None
|
||||||
|
created_at__lte: Optional[str] = None
|
||||||
|
created_at__gte: Optional[str] = None
|
||||||
|
created_by__in: Optional[list[str]] = None
|
||||||
|
updated_at__lte: Optional[str] = None
|
||||||
|
updated_at__gte: Optional[str] = None
|
||||||
|
updated_by__in: Optional[list[str]] = None
|
||||||
147
api/rpk-api/firm/core/models.py
Normal file
147
api/rpk-api/firm/core/models.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from datetime import datetime, UTC
|
||||||
|
from typing import Optional, TypedDict, ClassVar
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
from pydantic import BaseModel, Field, computed_field
|
||||||
|
|
||||||
|
class CrudDocumentConfig(TypedDict, total=False):
|
||||||
|
fulltext_search: list[str]
|
||||||
|
indexes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CrudDocument(BaseModel):
|
||||||
|
document_config: ClassVar[CrudDocumentConfig] = CrudDocumentConfig()
|
||||||
|
|
||||||
|
id: Optional[PydanticObjectId] = Field(default=None)
|
||||||
|
created_at: datetime = Field(default=datetime.now(UTC), nullable=False, title="Créé le")
|
||||||
|
created_by: Optional[PydanticObjectId] = Field(default=None, title="Créé par")
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), nullable=False, title="Modifié le")
|
||||||
|
updated_by: Optional[PydanticObjectId] = Field(default=None, title="Modifié par")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _id(self):
|
||||||
|
return self.id
|
||||||
|
|
||||||
|
@computed_field(title="Label")
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.compute_label()
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _collection_name(cls):
|
||||||
|
return cls.__name__
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_collection(cls, db) -> AsyncIOMotorCollection:
|
||||||
|
return db.get_collection(cls._collection_name())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_index(cls, db, index):
|
||||||
|
cls._get_collection(db).create_index(index)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, db, create_schema):
|
||||||
|
model_dict = create_schema.model_dump() | {"created_by": db.partner.id, "updated_by": db.partner.id}
|
||||||
|
document = cls.model_validate(model_dict).model_dump(mode="json")
|
||||||
|
result = await cls._get_collection(db).insert_one(document)
|
||||||
|
|
||||||
|
return await cls.get(db, result.inserted_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find(cls, db, filters):
|
||||||
|
return {
|
||||||
|
"collection": cls._get_collection(db),
|
||||||
|
"query_filter": filters.filter({}),
|
||||||
|
"sort": filters.sort(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list(cls, db, criteria={}):
|
||||||
|
result = []
|
||||||
|
for document in await cls._get_collection(db).find(criteria).to_list():
|
||||||
|
document["id"] = document.pop("_id")
|
||||||
|
result.append(cls.model_validate(document))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get(cls, db, model_id):
|
||||||
|
document = await cls._get_collection(db).find_one({"_id": model_id})
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
|
document["id"] = document.pop("_id")
|
||||||
|
return cls.model_validate(document)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update(cls, db, model, update_schema):
|
||||||
|
model_dict = update_schema.model_dump(mode="json") | {"updated_by": db.partner.id, "updated_at": datetime.now(UTC)}
|
||||||
|
update_query = {
|
||||||
|
"$set": {field: value for field, value in model_dict.items() if field!= "id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await cls._get_collection(db).update_one({"_id": model.id}, update_query)
|
||||||
|
new_model = await cls.get(db, model.id)
|
||||||
|
if new_model.label != model.label:
|
||||||
|
await cls._get_collection(db).update_one({"_id": model.id}, {"$set": {"label": new_model.label}})
|
||||||
|
return new_model
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete(cls, db, model):
|
||||||
|
await cls._get_collection(db).delete_one({"_id": model.id})
|
||||||
|
|
||||||
|
|
||||||
|
def text_area(*args, **kwargs):
|
||||||
|
kwargs['widget'] = {
|
||||||
|
"formlyConfig": {
|
||||||
|
"type": "textarea",
|
||||||
|
"props": {
|
||||||
|
"placeholder": "Leaving this field empty will cause formData property to be `null`",
|
||||||
|
"rows": kwargs['size'] if 'size' in kwargs else 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Field(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def RichtextMultiline(*args, **kwargs):
|
||||||
|
if 'props' not in kwargs:
|
||||||
|
kwargs['props'] = {}
|
||||||
|
|
||||||
|
kwargs['props']['richtext'] = True
|
||||||
|
kwargs['props']['multiline'] = True
|
||||||
|
|
||||||
|
return Field(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def RichtextSingleline(*args, **kwargs):
|
||||||
|
if 'props' not in kwargs:
|
||||||
|
kwargs['props'] = {}
|
||||||
|
|
||||||
|
kwargs['props']['richtext'] = True
|
||||||
|
kwargs['props']['multiline'] = False
|
||||||
|
|
||||||
|
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):
|
||||||
|
key: str
|
||||||
|
value: str = ""
|
||||||
64
api/rpk-api/firm/core/routes.py
Normal file
64
api/rpk-api/firm/core/routes.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from beanie import PydanticObjectId
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi_filter import FilterDepends
|
||||||
|
from fastapi_pagination import Page, add_pagination
|
||||||
|
from fastapi_pagination.ext.motor import paginate
|
||||||
|
|
||||||
|
from firm.core.depends import get_authed_tenant_registry
|
||||||
|
from firm.core.models import CrudDocument
|
||||||
|
from firm.core.schemas import Writer, Reader
|
||||||
|
|
||||||
|
def get_crud_router(model: CrudDocument, model_create: Writer, model_read: Reader, model_update: Writer, model_filter):
|
||||||
|
model_name = model.__name__
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=Page[model_read], response_description=f"{model_name} records retrieved")
|
||||||
|
async def read_list(filters: model_filter=FilterDepends(model_filter), reg=Depends(get_authed_tenant_registry)) -> Page[model_read]:
|
||||||
|
return await paginate(**model.find(reg.db, filters))
|
||||||
|
|
||||||
|
@router.post("/", response_description=f"{model_name} added to the database")
|
||||||
|
async def create(schema: model_create, reg=Depends(get_authed_tenant_registry)) -> model_read:
|
||||||
|
await schema.validate_foreign_key(reg.db)
|
||||||
|
record = await model.create(reg.db, schema)
|
||||||
|
return model_read.from_model(record)
|
||||||
|
|
||||||
|
@router.get("/{record_id}", response_description=f"{model_name} record retrieved")
|
||||||
|
async def read_one(record_id: PydanticObjectId, reg=Depends(get_authed_tenant_registry)) -> model_read:
|
||||||
|
record = await model.get(reg.db, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"{model_name} record not found!"
|
||||||
|
)
|
||||||
|
|
||||||
|
return model_read.from_model(record)
|
||||||
|
|
||||||
|
@router.put("/{record_id}", response_description=f"{model_name} record updated")
|
||||||
|
async def update(record_id: PydanticObjectId, schema: model_update, reg=Depends(get_authed_tenant_registry)) -> model_read:
|
||||||
|
record = await model.get(reg.db, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"{model_name} record not found!"
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await model.update(reg.db, record, schema)
|
||||||
|
return model_read.from_model(record)
|
||||||
|
|
||||||
|
@router.delete("/{record_id}", response_description=f"{model_name} record deleted from the database")
|
||||||
|
async def delete(record_id: PydanticObjectId, reg=Depends(get_authed_tenant_registry)) -> dict:
|
||||||
|
record = await model.get(reg.db, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"{model_name} record not found!"
|
||||||
|
)
|
||||||
|
|
||||||
|
await model.delete(reg.db, record)
|
||||||
|
return {
|
||||||
|
"message": f"{model_name} deleted successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_pagination(router)
|
||||||
|
return router
|
||||||
20
api/rpk-api/firm/core/schemas.py
Normal file
20
api/rpk-api/firm/core/schemas.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Reader(BaseModel):
|
||||||
|
id: Optional[PydanticObjectId] = Field(validation_alias="_id")
|
||||||
|
created_by: PydanticObjectId = Field(title="Créé par")
|
||||||
|
updated_by: PydanticObjectId = Field(title="Modifié par")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model):
|
||||||
|
schema = cls.model_validate(model, from_attributes=True)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
class Writer(BaseModel):
|
||||||
|
async def validate_foreign_key(self, db):
|
||||||
|
pass
|
||||||
77
api/rpk-api/firm/current_firm/__init__.py
Normal file
77
api/rpk-api/firm/current_firm/__init__.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from pydantic import Field, BaseModel
|
||||||
|
|
||||||
|
from firm.core.models import CrudDocument, CrudDocumentConfig
|
||||||
|
from firm.core.schemas import Writer, Reader
|
||||||
|
from firm.entity.schemas import EntityIndividualCreate, EntityCorporationCreate, EntityRead
|
||||||
|
|
||||||
|
class CurrentFirm(CrudDocument):
|
||||||
|
instance: str = Field()
|
||||||
|
firm: str = Field()
|
||||||
|
entity_id: PydanticObjectId = Field()
|
||||||
|
primary_color: str = Field()
|
||||||
|
secondary_color: str = Field()
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
if isinstance(other, dict):
|
||||||
|
return self.instance == other["instance"] and self.firm == other["firm"]
|
||||||
|
return super().__eq__(other)
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return f"{self.instance} / {self.firm}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_current(cls, db):
|
||||||
|
document = await cls._get_collection(db).find_one({})
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
|
document["id"] = document.pop("_id")
|
||||||
|
return cls.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentFirmSchemaRead(BaseModel):
|
||||||
|
id: Optional[PydanticObjectId]
|
||||||
|
entity: EntityRead
|
||||||
|
partner: EntityRead
|
||||||
|
partner_list: list[EntityRead]
|
||||||
|
instance: str
|
||||||
|
firm: str
|
||||||
|
primary_color: str
|
||||||
|
secondary_color: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model_and_entities(cls, model, entity, partner, partner_list):
|
||||||
|
schema = cls(**model.model_dump(mode="json"), entity=entity, partner=partner, partner_list=partner_list)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class CurrentFirmSchemaCreate(Writer):
|
||||||
|
corporation: EntityCorporationCreate = Field(title="Informations sur la firme")
|
||||||
|
owner: EntityIndividualCreate = Field(title="Informations sur le dirigeant")
|
||||||
|
position: str = Field(title="Poste")
|
||||||
|
|
||||||
|
primary_color: str = Field()
|
||||||
|
secondary_color: str = Field()
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentFirmSchemaUpdate(Writer):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Partner(CrudDocument):
|
||||||
|
document_config = CrudDocumentConfig(
|
||||||
|
indexes=["user_id", "entity_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: PydanticObjectId = Field()
|
||||||
|
entity_id: PydanticObjectId = Field()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_user_id(cls, db, user_id):
|
||||||
|
document = await cls._get_collection(db).find_one({"user_id": str(user_id)})
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
|
document["id"] = document.pop("_id")
|
||||||
|
return cls.model_validate(document)
|
||||||
55
api/rpk-api/firm/current_firm/routes.py
Normal file
55
api/rpk-api/firm/current_firm/routes.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from firm.core.depends import get_authed_tenant_registry, get_uninitialized_registry
|
||||||
|
from firm.current_firm import CurrentFirm, CurrentFirmSchemaRead, CurrentFirmSchemaCreate, CurrentFirmSchemaUpdate, Partner
|
||||||
|
from firm.entity.models import Entity, Employee
|
||||||
|
from firm.entity.schemas import EntityRead
|
||||||
|
|
||||||
|
current_firm_router = APIRouter()
|
||||||
|
|
||||||
|
@current_firm_router.get("/", response_model=CurrentFirmSchemaRead, response_description=f"Current Firm records retrieved")
|
||||||
|
async def read(reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead:
|
||||||
|
document = await CurrentFirm.get_current(reg.db)
|
||||||
|
firm_entity = await Entity.get(reg.db, document.entity_id)
|
||||||
|
partner = await Partner.get_by_user_id(reg.db, reg.user.id)
|
||||||
|
partner = await Entity.get(reg.db, partner.entity_id)
|
||||||
|
partner_list = await Partner.list(reg.db)
|
||||||
|
partner_list = await Entity.list(reg.db, {"_id": {"$in": [p.entity_id for p in partner_list]}})
|
||||||
|
return CurrentFirmSchemaRead.from_model_and_entities(
|
||||||
|
document,
|
||||||
|
EntityRead.from_model(firm_entity),
|
||||||
|
EntityRead.from_model(partner),
|
||||||
|
[EntityRead.from_model(p) for p in partner_list]
|
||||||
|
)
|
||||||
|
|
||||||
|
@current_firm_router.post("/", response_description=f"Current Firm added to the database")
|
||||||
|
async def create(schema: CurrentFirmSchemaCreate, reg=Depends(get_uninitialized_registry)) -> CurrentFirmSchemaRead:
|
||||||
|
owner_entity = await Entity.create(reg.db, schema.owner)
|
||||||
|
await Partner.create(reg.db, Partner(user_id=reg.user.id, entity_id=owner_entity.id))
|
||||||
|
|
||||||
|
corporation_schema = schema.corporation
|
||||||
|
corporation_schema.entity_data.employees.append(Employee(entity_id=owner_entity.id, position=schema.position))
|
||||||
|
corp = await Entity.create(reg.db, corporation_schema)
|
||||||
|
|
||||||
|
firm = await CurrentFirm.create(reg.db, CurrentFirm(
|
||||||
|
instance=reg.instance,
|
||||||
|
firm=reg.firm,
|
||||||
|
entity_id=corp.id,
|
||||||
|
primary_color=schema.primary_color,
|
||||||
|
secondary_color=schema.secondary_color,
|
||||||
|
))
|
||||||
|
|
||||||
|
await Partner.create(Partner(user_id=reg.user.id, entity_id=owner_entity.id))
|
||||||
|
|
||||||
|
return CurrentFirmSchemaRead.from_model_and_entities(
|
||||||
|
firm,
|
||||||
|
EntityRead.from_model(corp),
|
||||||
|
EntityRead.from_model(owner_entity),
|
||||||
|
[EntityRead.from_model(owner_entity)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@current_firm_router.put("/", response_description=f"Current Firm record updated")
|
||||||
|
async def update(schema: CurrentFirmSchemaUpdate, reg=Depends(get_authed_tenant_registry)) -> CurrentFirmSchemaRead:
|
||||||
|
document = await CurrentFirm.get_current(reg.db)
|
||||||
|
document = await CurrentFirm.update(reg.db, document, schema)
|
||||||
|
return CurrentFirmSchemaRead.from_model(document)
|
||||||
18
api/rpk-api/firm/db.py
Normal file
18
api/rpk-api/firm/db.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import motor.motor_asyncio
|
||||||
|
|
||||||
|
MONGO_USERNAME = os.getenv("MONGO_INITDB_ROOT_USERNAME")
|
||||||
|
MONGO_PASSWORD = os.getenv("MONGO_INITDB_ROOT_PASSWORD")
|
||||||
|
|
||||||
|
DATABASE_URL = f"mongodb://{MONGO_USERNAME}:{MONGO_PASSWORD}@mongo:27017"
|
||||||
|
|
||||||
|
client = motor.motor_asyncio.AsyncIOMotorClient(
|
||||||
|
DATABASE_URL, uuidRepresentation="standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_db():
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def get_db_client():
|
||||||
|
yield client
|
||||||
1
api/rpk-api/firm/entity/__init__.py
Normal file
1
api/rpk-api/firm/entity/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from firm.entity.routes import router as entity_router
|
||||||
97
api/rpk-api/firm/entity/models.py
Normal file
97
api/rpk-api/firm/entity/models.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from datetime import date
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from fastapi_filter import FilterDepends, with_prefix
|
||||||
|
from pydantic import Field, BaseModel, ConfigDict
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
|
||||||
|
from firm.core.models import CrudDocument, ForeignKey, CrudDocumentConfig
|
||||||
|
from firm.core.filter import Filter, FilterSchema
|
||||||
|
|
||||||
|
|
||||||
|
class EntityType(BaseModel):
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
class EntityTypeEnum(str, Enum):
|
||||||
|
individual = 'individual'
|
||||||
|
corporation = 'corporation'
|
||||||
|
institution = 'institution'
|
||||||
|
|
||||||
|
class Individual(EntityType):
|
||||||
|
model_config = ConfigDict(title='Particulier')
|
||||||
|
|
||||||
|
type: Literal['individual'] = 'individual'
|
||||||
|
firstname: str = Field(title='Prénom')
|
||||||
|
middlename: str = Field(default="", title='Autres prénoms')
|
||||||
|
lastname: str = Field(title='Nom de famille')
|
||||||
|
surnames: List[str] = Field(
|
||||||
|
default=[],
|
||||||
|
props={"items_per_row": "4", "numbered": True},
|
||||||
|
title="Surnoms"
|
||||||
|
)
|
||||||
|
day_of_birth: Optional[date] = Field(default=None, title='Date de naissance')
|
||||||
|
place_of_birth: Optional[str] = Field(default="", title='Lieu de naissance')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
# if len(self.surnames) > 0:
|
||||||
|
# return f"{self.firstname} \"{self.surnames[0]}\" {self.lastname}"
|
||||||
|
return f"{self.firstname} {self.lastname}"
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(BaseModel):
|
||||||
|
model_config = ConfigDict(title='Fiche Employé')
|
||||||
|
|
||||||
|
position: str = Field(title='Poste')
|
||||||
|
entity_id: PydanticObjectId = ForeignKey("entities", "Entity", title='Employé')
|
||||||
|
|
||||||
|
|
||||||
|
class Corporation(EntityType):
|
||||||
|
model_config = ConfigDict(title='Entreprise')
|
||||||
|
|
||||||
|
type: Literal['corporation'] = 'corporation'
|
||||||
|
title: str = Field(title='Dénomination sociale')
|
||||||
|
activity: str = Field(title='Activité')
|
||||||
|
employees: List[Employee] = Field(default=[], title='Employés')
|
||||||
|
|
||||||
|
|
||||||
|
class Institution(Corporation):
|
||||||
|
model_config = ConfigDict(title='Institution')
|
||||||
|
|
||||||
|
type: Literal['institution'] = 'institution'
|
||||||
|
|
||||||
|
|
||||||
|
class Entity(CrudDocument):
|
||||||
|
"""
|
||||||
|
Fiche d'un client
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(title='Client')
|
||||||
|
document_config = CrudDocumentConfig(
|
||||||
|
indexes=["entity_data.type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
|
||||||
|
address: str = Field(default="", title='Adresse')
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
if not self.entity_data:
|
||||||
|
return ""
|
||||||
|
return self.entity_data.label
|
||||||
|
|
||||||
|
|
||||||
|
class EntityDataFilter(Filter):
|
||||||
|
type__in: Optional[list[str]] = None
|
||||||
|
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = EntityType
|
||||||
|
|
||||||
|
|
||||||
|
class EntityFilters(FilterSchema):
|
||||||
|
entity_data: Optional[EntityDataFilter] = FilterDepends(with_prefix("entity_data", EntityDataFilter))
|
||||||
|
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = Entity
|
||||||
|
search_model_fields = ["label"]
|
||||||
5
api/rpk-api/firm/entity/routes.py
Normal file
5
api/rpk-api/firm/entity/routes.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from firm.core.routes import get_crud_router
|
||||||
|
from firm.entity.models import Entity, EntityFilters
|
||||||
|
from firm.entity.schemas import EntityCreate, EntityRead, EntityUpdate
|
||||||
|
|
||||||
|
router = get_crud_router(Entity, EntityCreate, EntityRead, EntityUpdate, EntityFilters)
|
||||||
22
api/rpk-api/firm/entity/schemas.py
Normal file
22
api/rpk-api/firm/entity/schemas.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic import Field, ConfigDict
|
||||||
|
|
||||||
|
from firm.entity.models import Entity, Institution, Individual, Corporation
|
||||||
|
from firm.core.schemas import Writer, Reader
|
||||||
|
|
||||||
|
class EntityRead(Reader, Entity):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class EntityCreate(Writer):
|
||||||
|
model_config = ConfigDict(title="Création d'un client")
|
||||||
|
|
||||||
|
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
|
||||||
|
address: str = Field(default="", title='Adresse')
|
||||||
|
|
||||||
|
class EntityIndividualCreate(EntityCreate):
|
||||||
|
entity_data: Individual
|
||||||
|
|
||||||
|
class EntityCorporationCreate(EntityCreate):
|
||||||
|
entity_data: Corporation
|
||||||
|
|
||||||
|
class EntityUpdate(EntityCreate):
|
||||||
|
pass
|
||||||
31
api/rpk-api/firm/init_db.py
Normal file
31
api/rpk-api/firm/init_db.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from firm.contract.models import Contract, ContractDraft
|
||||||
|
from firm.core.depends import Registry
|
||||||
|
from firm.current_firm import CurrentFirm, Partner
|
||||||
|
from firm.db import client
|
||||||
|
from firm.entity.models import Entity
|
||||||
|
from firm.template.models import ContractTemplate, ProvisionTemplate
|
||||||
|
|
||||||
|
from hub.firm import Firm
|
||||||
|
|
||||||
|
collections = [CurrentFirm, Entity, Partner, Contract, ContractDraft, ContractTemplate, ProvisionTemplate]
|
||||||
|
|
||||||
|
logger = logging.getLogger('uvicorn.error')
|
||||||
|
|
||||||
|
async def create_documents_indexes(db):
|
||||||
|
for collection in collections:
|
||||||
|
if "indexes" in collection.document_config:
|
||||||
|
for field in collection.document_config["indexes"]:
|
||||||
|
collection.create_index(db, field)
|
||||||
|
|
||||||
|
async def init_all_db():
|
||||||
|
logger.info("[FRM] Creating index for firms")
|
||||||
|
for firm in await Firm.find({}).to_list():
|
||||||
|
reg = Registry(client, firm.instance, firm.firm)
|
||||||
|
await reg.current_firm
|
||||||
|
await create_documents_indexes(reg.db)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
await init_all_db()
|
||||||
9
api/rpk-api/firm/template/__init__.py
Normal file
9
api/rpk-api/firm/template/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from firm.template.routes_contract import router as contract_router
|
||||||
|
from firm.template.routes_provision import router as provision_router
|
||||||
|
|
||||||
|
template_router = APIRouter()
|
||||||
|
|
||||||
|
template_router.include_router(provision_router, prefix="/provisions", )
|
||||||
|
template_router.include_router(contract_router, prefix="/contracts", )
|
||||||
77
api/rpk-api/firm/template/models.py
Normal file
77
api/rpk-api/firm/template/models.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
from beanie import PydanticObjectId
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
from firm.core.models import CrudDocument, RichtextMultiline, RichtextSingleline, DictionaryEntry, ForeignKey
|
||||||
|
from firm.core.filter import Filter, FilterSchema
|
||||||
|
|
||||||
|
|
||||||
|
class PartyTemplate(BaseModel):
|
||||||
|
model_config = ConfigDict(title="Partie")
|
||||||
|
|
||||||
|
entity_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Partie")
|
||||||
|
part: str = Field(title="Rôle")
|
||||||
|
representative_id: Optional[PydanticObjectId] = ForeignKey("entities", "Entity", default=None, title="Représentant")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_html_tags(text):
|
||||||
|
"""Remove html tags from a string"""
|
||||||
|
import re
|
||||||
|
clean = re.compile('<.*?>')
|
||||||
|
return re.sub(clean, '', text)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplate(CrudDocument):
|
||||||
|
"""
|
||||||
|
Modèle de clause à décliner
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(title="Template de clause")
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = RichtextSingleline(title="Titre")
|
||||||
|
body: str = RichtextMultiline(title="Corps")
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplateReference(BaseModel):
|
||||||
|
model_config = ConfigDict(title="Clause")
|
||||||
|
|
||||||
|
provision_template_id: PydanticObjectId = ForeignKey(
|
||||||
|
"templates/provisions",
|
||||||
|
"ProvisionTemplate",
|
||||||
|
['title', 'body'],
|
||||||
|
props={"parametrized": True},
|
||||||
|
title="Template de clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplate(CrudDocument):
|
||||||
|
"""
|
||||||
|
Modèle de contrat à décliner
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(title="Template de contrat")
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = Field(title="Titre")
|
||||||
|
parties: List[PartyTemplate] = Field(default=[], title="Parties")
|
||||||
|
provisions: List[ProvisionTemplateReference] = Field(default=[], title="Clauses")
|
||||||
|
variables: List[DictionaryEntry] = Field(default=[], title="Variables")
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\""
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateFilters(FilterSchema):
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = ContractTemplate
|
||||||
|
search_model_fields = ["label"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplateFilters(FilterSchema):
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = ProvisionTemplate
|
||||||
|
search_model_fields = ["label"]
|
||||||
5
api/rpk-api/firm/template/routes_contract.py
Normal file
5
api/rpk-api/firm/template/routes_contract.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from firm.core.routes import get_crud_router
|
||||||
|
from firm.template.models import ContractTemplate, ContractTemplateFilters
|
||||||
|
from firm.template.schemas import ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate
|
||||||
|
|
||||||
|
router = get_crud_router(ContractTemplate, ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate, ContractTemplateFilters)
|
||||||
5
api/rpk-api/firm/template/routes_provision.py
Normal file
5
api/rpk-api/firm/template/routes_provision.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from firm.core.routes import get_crud_router
|
||||||
|
from firm.template.models import ProvisionTemplate, ProvisionTemplateFilters
|
||||||
|
from firm.template.schemas import ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate
|
||||||
|
|
||||||
|
router = get_crud_router(ProvisionTemplate, ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate, ProvisionTemplateFilters)
|
||||||
51
api/rpk-api/firm/template/schemas.py
Normal file
51
api/rpk-api/firm/template/schemas.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pydantic import Field, ConfigDict
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from firm.template.models import ContractTemplate, ProvisionTemplate, PartyTemplate, ProvisionTemplateReference, DictionaryEntry
|
||||||
|
from firm.core.schemas import Writer, Reader
|
||||||
|
from firm.core.models import RichtextMultiline, RichtextSingleline
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateRead(Reader, ContractTemplate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateCreate(Writer):
|
||||||
|
model_config = ConfigDict(title="Template de Contrat")
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = Field(title="Titre")
|
||||||
|
parties: List[PartyTemplate] = Field(
|
||||||
|
default=[],
|
||||||
|
props={"items_per_row": "2"},
|
||||||
|
title="Parties")
|
||||||
|
provisions: List[ProvisionTemplateReference] = Field(
|
||||||
|
default=[],
|
||||||
|
props={"items_per_row": "1", "numbered": True},
|
||||||
|
title="Clauses"
|
||||||
|
)
|
||||||
|
variables: List[DictionaryEntry] = Field(
|
||||||
|
default=[],
|
||||||
|
props={"display": "dictionary", "required": False},
|
||||||
|
title="Variables"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateUpdate(ContractTemplateCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplateRead(Reader, ProvisionTemplate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplateCreate(Writer):
|
||||||
|
model_config = ConfigDict(title="Template de Clause")
|
||||||
|
|
||||||
|
name: str = Field(title="Nom")
|
||||||
|
title: str = RichtextSingleline(title="Titre")
|
||||||
|
body: str = RichtextMultiline(title="Corps")
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionTemplateUpdate(ProvisionTemplateCreate):
|
||||||
|
pass
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
from datetime import datetime
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from beanie import Document, PydanticObjectId
|
from hub.auth import auth_router, register_router, password_router, verification_router, users_router, \
|
||||||
from pydantic import Field, computed_field
|
google_oauth_router, discord_oauth_router
|
||||||
|
from hub.firm.routes import router as firm_router
|
||||||
|
|
||||||
|
hub_router = APIRouter()
|
||||||
|
|
||||||
class CrudDocument(Document):
|
hub_router.include_router(register_router, tags=["Auth"], )
|
||||||
_id: str
|
hub_router.include_router(auth_router, prefix="/auth", tags=["Auth"], )
|
||||||
created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le")
|
hub_router.include_router(google_oauth_router, prefix="/auth/google", tags=["Auth"])
|
||||||
created_by: PydanticObjectId = Field()
|
hub_router.include_router(discord_oauth_router, prefix="/auth/discord", tags=["Auth"])
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le")
|
hub_router.include_router(verification_router, prefix="/auth/verification", tags=["Auth"], )
|
||||||
updated_by: PydanticObjectId = Field()
|
hub_router.include_router(users_router, prefix="/users", tags=["Users"], )
|
||||||
|
hub_router.include_router(password_router, prefix="/users", tags=["Users"], )
|
||||||
@computed_field
|
hub_router.include_router(firm_router, prefix="/users/firms", tags=["Users"], )
|
||||||
def label(self) -> str:
|
|
||||||
return self.compute_label()
|
|
||||||
|
|
||||||
def compute_label(self) -> str:
|
|
||||||
return ""
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from beanie import PydanticObjectId
|
from beanie import PydanticObjectId, Document
|
||||||
from fastapi import Depends, Response, status
|
from fastapi import Depends, Response, status, APIRouter
|
||||||
from fastapi_users import BaseUserManager, FastAPIUsers, schemas, models
|
from fastapi_users import BaseUserManager, FastAPIUsers, schemas, models
|
||||||
from fastapi_users.authentication import AuthenticationBackend, BearerTransport, CookieTransport, Strategy
|
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, Strategy
|
||||||
from fastapi_users.authentication.strategy import AccessTokenDatabase, DatabaseStrategy
|
from fastapi_users.authentication.strategy import AccessTokenDatabase, DatabaseStrategy
|
||||||
from fastapi_users_db_beanie.access_token import BeanieBaseAccessTokenDocument, BeanieAccessTokenDatabase
|
from fastapi_users_db_beanie.access_token import BeanieBaseAccessToken, BeanieAccessTokenDatabase
|
||||||
from fastapi_users.openapi import OpenAPIResponseType
|
from fastapi_users.openapi import OpenAPIResponseType
|
||||||
from httpx_oauth.clients.google import GoogleOAuth2
|
from httpx_oauth.clients.google import GoogleOAuth2
|
||||||
from httpx_oauth.clients.discord import DiscordOAuth2
|
from httpx_oauth.clients.discord import DiscordOAuth2
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from hub.user import User, get_user_db
|
from hub.user import User, get_user_db
|
||||||
from hub.user.schemas import UserSchema
|
from hub.user.schemas import UserSchema, UserUpdateSchema
|
||||||
|
|
||||||
|
|
||||||
SECRET = os.getenv("FASTAPI_USERS_SECRET")
|
SECRET = os.getenv("FASTAPI_USERS_SECRET")
|
||||||
@@ -23,7 +23,7 @@ discord_oauth_client = DiscordOAuth2(os.getenv("DISCORD_CLIENT_ID"), os.getenv("
|
|||||||
TOKEN_LIFETIME = 3600
|
TOKEN_LIFETIME = 3600
|
||||||
|
|
||||||
|
|
||||||
class AccessToken(BeanieBaseAccessTokenDocument):
|
class AccessToken(BeanieBaseAccessToken, Document):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def get_access_token_db():
|
async def get_access_token_db():
|
||||||
@@ -77,19 +77,20 @@ class CookieTransportOauth(CookieTransport):
|
|||||||
|
|
||||||
cookie_transport = CookieTransportMe(cookie_name="rpkapiusersauth")
|
cookie_transport = CookieTransportMe(cookie_name="rpkapiusersauth")
|
||||||
auth_backend = AuthenticationBackendMe(name="db", transport=cookie_transport, get_strategy=get_database_strategy, )
|
auth_backend = AuthenticationBackendMe(name="db", transport=cookie_transport, get_strategy=get_database_strategy, )
|
||||||
|
|
||||||
fastapi_users = FastAPIUsers[User, PydanticObjectId](get_user_manager, [auth_backend])
|
fastapi_users = FastAPIUsers[User, PydanticObjectId](get_user_manager, [auth_backend])
|
||||||
|
|
||||||
|
get_current_user = fastapi_users.current_user(active=True)
|
||||||
|
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
||||||
|
|
||||||
|
|
||||||
auth_router = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
|
auth_router = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
|
||||||
register_router = fastapi_users.get_register_router(UserSchema, schemas.BaseUserCreate)
|
register_router = fastapi_users.get_register_router(UserSchema, schemas.BaseUserCreate)
|
||||||
password_router = fastapi_users.get_reset_password_router()
|
password_router = fastapi_users.get_reset_password_router()
|
||||||
verification_router = fastapi_users.get_verify_router(UserSchema)
|
verification_router = fastapi_users.get_verify_router(UserSchema)
|
||||||
users_router = fastapi_users.get_users_router(UserSchema, schemas.BaseUserUpdate)
|
users_router = fastapi_users.get_users_router(UserSchema, UserUpdateSchema)
|
||||||
|
|
||||||
cookie_transport = CookieTransportOauth(cookie_name="rpkapiusersauth")
|
cookie_transport = CookieTransportOauth(cookie_name="rpkapiusersauth")
|
||||||
auth_backend = AuthenticationBackend(name="db", transport=cookie_transport, get_strategy=get_database_strategy, )
|
auth_backend = AuthenticationBackend(name="db", transport=cookie_transport, get_strategy=get_database_strategy, )
|
||||||
|
|
||||||
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, auth_backend, SECRET, is_verified_by_default=True)
|
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, auth_backend, SECRET, is_verified_by_default=True)
|
||||||
discord_oauth_router = fastapi_users.get_oauth_router(discord_oauth_client, auth_backend, SECRET, is_verified_by_default=True)
|
discord_oauth_router = fastapi_users.get_oauth_router(discord_oauth_client, auth_backend, SECRET, is_verified_by_default=True)
|
||||||
|
|
||||||
get_current_user = fastapi_users.current_user(active=True)
|
|
||||||
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
|
||||||
|
|||||||
19
api/rpk-api/hub/core/__init__.py
Normal file
19
api/rpk-api/hub/core/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from beanie import Document, PydanticObjectId
|
||||||
|
from pydantic import Field, computed_field
|
||||||
|
|
||||||
|
|
||||||
|
class CrudDocument(Document):
|
||||||
|
_id: str
|
||||||
|
created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le")
|
||||||
|
created_by: PydanticObjectId = Field()
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le")
|
||||||
|
updated_by: PydanticObjectId = Field()
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.compute_label()
|
||||||
|
|
||||||
|
def compute_label(self) -> str:
|
||||||
|
return ""
|
||||||
@@ -2,27 +2,32 @@ from beanie import PydanticObjectId
|
|||||||
from pydantic import Field, BaseModel
|
from pydantic import Field, BaseModel
|
||||||
from pymongo import IndexModel
|
from pymongo import IndexModel
|
||||||
|
|
||||||
from hub import CrudDocument
|
from hub.core import CrudDocument
|
||||||
|
|
||||||
class Firm(CrudDocument):
|
class Firm(CrudDocument):
|
||||||
name: str = Field()
|
|
||||||
instance: str = Field()
|
instance: str = Field()
|
||||||
|
firm: str = Field()
|
||||||
owner: PydanticObjectId = Field()
|
owner: PydanticObjectId = Field()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_name(cls, instance, firm):
|
||||||
|
return cls.find_one({"instance": instance, "firm": firm})
|
||||||
|
|
||||||
def compute_label(self) -> str:
|
def compute_label(self) -> str:
|
||||||
return self.name
|
return f"{self.instance} / {self.firm}"
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
indexes = [
|
indexes = [
|
||||||
IndexModel(["name", "instance"], unique=True),
|
IndexModel(["instance", "firm"], unique=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
class FirmRead(BaseModel):
|
class FirmRead(BaseModel):
|
||||||
instance: str = Field()
|
instance: str = Field()
|
||||||
name: str = Field()
|
firm: str = Field()
|
||||||
|
|
||||||
class FirmCreate(FirmRead):
|
class FirmCreate(FirmRead):
|
||||||
pass
|
instance: str = Field(max_length=32, min_length=3, pattern="^[0-9a-z-]+$")
|
||||||
|
firm: str = Field(max_length=32, min_length=3, pattern="^[0-9a-z-]+$")
|
||||||
|
|
||||||
class FirmUpdate(FirmRead):
|
class FirmUpdate(BaseModel):
|
||||||
pass
|
owner: PydanticObjectId = Field()
|
||||||
|
|||||||
@@ -1,47 +1,46 @@
|
|||||||
from beanie import PydanticObjectId
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from hub.auth import get_current_user
|
from hub.auth import get_current_user
|
||||||
from hub.firm import Firm, FirmRead, FirmCreate, FirmUpdate
|
from hub.firm import Firm, FirmRead, FirmCreate, FirmUpdate
|
||||||
|
|
||||||
model = Firm
|
|
||||||
model_read = FirmRead
|
|
||||||
model_create = FirmCreate
|
|
||||||
model_update = FirmUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/", response_description="{} added to the database".format(model.__name__))
|
@router.get("/", response_model=list[FirmRead], response_description="List of firms owned by the current user")
|
||||||
async def create(item: model_create, user=Depends(get_current_user)) -> dict:
|
async def read_list(user=Depends(get_current_user)) -> list[FirmRead]:
|
||||||
exists = await Firm.find_one({"name": item.name, "instance": item.instance})
|
return await Firm.find({ "owner": user.id}).to_list()
|
||||||
|
|
||||||
|
@router.post("/", response_description="Firm added to the database")
|
||||||
|
async def create(item: FirmCreate, user=Depends(get_current_user)) -> FirmRead:
|
||||||
|
exists = await Firm.get_by_name(item.instance, item.firm)
|
||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=400, detail="Firm already exists")
|
raise HTTPException(status_code=400, detail="Firm already exists")
|
||||||
|
|
||||||
item.created_by = user.id
|
record = Firm(created_by=user.id, updated_by=user.id, owner=user.id, **item.model_dump())
|
||||||
item.updated_by = user.id
|
o = await record.create()
|
||||||
item.owner = user.id
|
user.firms.append({"instance": item.instance, "firm": item.firm})
|
||||||
o = await model(**item.model_dump()).create()
|
await user.save()
|
||||||
return model_read(**o.model_dump())
|
return FirmRead(**o.model_dump())
|
||||||
|
|
||||||
@router.get("/{id}", response_description="{} record retrieved".format(model.__name__))
|
@router.get("/{instance}/{firm}", response_description="Firm retrieved")
|
||||||
async def read_id(id: PydanticObjectId, user=Depends(get_current_user)) -> model_read:
|
async def read_id(instance: str, firm: str, user=Depends(get_current_user)) -> FirmRead:
|
||||||
item = await model.get(id)
|
item = await Firm.get_by_name(instance, firm)
|
||||||
return model_read(**item.model_dump())
|
if not item or not user.belong_to(item) not in user.firms:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
return FirmRead(**item.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_description="{} record updated".format(model.__name__))
|
@router.put("/{instance}/{firm}", response_description="Firm updated")
|
||||||
async def update(id: PydanticObjectId, req: model_update, user=Depends(get_current_user)) -> model_read:
|
async def update(instance: str, firm: str, req: FirmUpdate, user=Depends(get_current_user)) -> FirmRead:
|
||||||
item = await model.get(id)
|
item = await Firm.get_by_name(instance, firm)
|
||||||
if not item:
|
if not item or not user.belong_to(item) not in user.firms:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
status_code=404,
|
|
||||||
detail="{} record not found!".format(model.__name__)
|
|
||||||
)
|
|
||||||
|
|
||||||
if item.owner != user.id:
|
if item.owner != user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Insufficient credentials to modify {} record".format(model.__name__)
|
detail="Insufficient credentials to modify Firm"
|
||||||
)
|
)
|
||||||
|
|
||||||
req = {k: v for k, v in req.model_dump().items() if v is not None}
|
req = {k: v for k, v in req.model_dump().items() if v is not None}
|
||||||
@@ -50,23 +49,21 @@ async def update(id: PydanticObjectId, req: model_update, user=Depends(get_curre
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
await item.update(update_query)
|
await item.update(update_query)
|
||||||
return model_read(**item.dict())
|
return FirmRead(**item.dict())
|
||||||
|
|
||||||
|
@router.delete("/{instance}/{firm}", response_description="Firm deleted from the database")
|
||||||
|
async def delete(instance: str, firm: str, user=Depends(get_current_user)) -> dict:
|
||||||
|
item = await Firm.get_by_name(instance, firm)
|
||||||
|
if not item or not user.belong_to(item) not in user.firms:
|
||||||
|
raise HTTPException(status_code=404, detail="Firm not found")
|
||||||
|
|
||||||
@router.delete("/{id}", response_description="{} record deleted from the database".format(model.__name__))
|
|
||||||
async def delete(id: PydanticObjectId, user=Depends(get_current_user)) -> dict:
|
|
||||||
item = await model.get(id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="{} record not found!".format(model.__name__)
|
|
||||||
)
|
|
||||||
if item.owner != user.id:
|
if item.owner != user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Insufficient credentials delete {} record".format(model.__name__)
|
detail="Insufficient credentials delete Firm"
|
||||||
)
|
)
|
||||||
|
|
||||||
await item.delete()
|
await item.delete()
|
||||||
return {
|
return {
|
||||||
"message": "{} deleted successfully".format(model.__name__)
|
"message": "Firm deleted successfully"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
from fastapi_users_db_beanie import BaseOAuthAccount, BeanieUserDatabase, BeanieBaseUserDocument
|
from beanie import Document
|
||||||
|
from fastapi_users_db_beanie import BaseOAuthAccount, BeanieUserDatabase, BeanieBaseUser
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from hub.firm import FirmRead
|
||||||
|
from hub.user.schemas import UserSchema, UserUpdateSchema
|
||||||
|
|
||||||
|
|
||||||
class OAuthAccount(BaseOAuthAccount):
|
class OAuthAccount(BaseOAuthAccount):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class User(BeanieBaseUserDocument):
|
class User(BeanieBaseUser, Document):
|
||||||
oauth_accounts: list[OAuthAccount] = Field(default_factory=list)
|
oauth_accounts: list[OAuthAccount] = Field(default_factory=list)
|
||||||
|
firms: list[FirmRead] = Field(default_factory=list)
|
||||||
|
|
||||||
class UserDatabase(BeanieUserDatabase):
|
class UserDatabase(BeanieUserDatabase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
from beanie import PydanticObjectId
|
from beanie import PydanticObjectId
|
||||||
from fastapi_users.schemas import BaseUser
|
from fastapi_users.schemas import BaseUser, BaseUserUpdate
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from hub.firm import FirmRead
|
||||||
|
|
||||||
|
|
||||||
class UserSchema(BaseUser[PydanticObjectId]):
|
class UserSchema(BaseUser[PydanticObjectId]):
|
||||||
|
firms: list[FirmRead] = Field()
|
||||||
|
|
||||||
|
def belongs_to(self, firm):
|
||||||
|
for f in self.firms:
|
||||||
|
if f.instance == firm.instance and f.firm == firm.firm :
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
class UserUpdateSchema(BaseUserUpdate):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from hub import hub_router
|
||||||
from hub.db import init_db as hub_init_db
|
from hub.db import init_db as hub_init_db
|
||||||
from hub.auth import auth_router, register_router, password_router, verification_router, users_router, \
|
|
||||||
google_oauth_router, discord_oauth_router
|
from firm import firm_router
|
||||||
from hub.firm.routes import router as firm_router
|
from firm.init_db import init_db as firm_init_db
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -14,18 +16,12 @@ if __name__ == '__main__':
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await hub_init_db()
|
await hub_init_db()
|
||||||
|
await firm_init_db()
|
||||||
# create_db_and_tables()
|
# create_db_and_tables()
|
||||||
# create_admin_user()
|
# create_admin_user()
|
||||||
yield
|
yield
|
||||||
# do something before end
|
# do something before end
|
||||||
|
|
||||||
app = FastAPI(root_path="/api/v1", lifespan=lifespan)
|
app = FastAPI(root_path="/api/v1", lifespan=lifespan)
|
||||||
|
app.include_router(hub_router, prefix="/hub")
|
||||||
app.include_router(register_router, tags=["Auth"], )
|
app.include_router(firm_router, prefix="/firm")
|
||||||
app.include_router(auth_router, prefix="/auth", tags=["Auth"], )
|
|
||||||
app.include_router(google_oauth_router, prefix="/auth/google", tags=["Auth"])
|
|
||||||
app.include_router(discord_oauth_router, prefix="/auth/discord", tags=["Auth"])
|
|
||||||
app.include_router(verification_router, prefix="/auth/verification", tags=["Auth"], )
|
|
||||||
app.include_router(users_router, prefix="/users", tags=["Users"], )
|
|
||||||
app.include_router(password_router, prefix="/users", tags=["Users"], )
|
|
||||||
app.include_router(firm_router, prefix="/firms", tags=["Firms"], )
|
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ services:
|
|||||||
- "traefik.http.routers.gui.rule=PathPrefix(`/`)"
|
- "traefik.http.routers.gui.rule=PathPrefix(`/`)"
|
||||||
- "traefik.http.services.gui.loadbalancer.server.port=5173"
|
- "traefik.http.services.gui.loadbalancer.server.port=5173"
|
||||||
|
|
||||||
|
i18n:
|
||||||
|
build:
|
||||||
|
context: ./i18n
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./i18n/app/src:/app/src
|
||||||
|
- ./gui/rpk-gui/public:/app/public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.i18n.entrypoints=web"
|
||||||
|
- "traefik.http.routers.i18n.rule=PathPrefix(`/locales/add`)"
|
||||||
|
- "traefik.http.services.i18n.loadbalancer.server.port=8100"
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
image: traefik:latest
|
image: traefik:latest
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
strict-peer-dependencies=false
|
|
||||||
2277
gui/rpk-gui/package-lock.json
generated
2277
gui/rpk-gui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
|
"@mui/x-date-pickers": "^8.3.0",
|
||||||
"@refinedev/cli": "^2.16.21",
|
"@refinedev/cli": "^2.16.21",
|
||||||
"@refinedev/core": "^4.47.1",
|
"@refinedev/core": "^4.47.1",
|
||||||
"@refinedev/devtools": "^1.1.32",
|
"@refinedev/devtools": "^1.1.32",
|
||||||
@@ -22,10 +23,26 @@
|
|||||||
"@rjsf/mui": "^5.24.1",
|
"@rjsf/mui": "^5.24.1",
|
||||||
"@rjsf/utils": "^5.24.1",
|
"@rjsf/utils": "^5.24.1",
|
||||||
"@rjsf/validator-ajv8": "^5.24.1",
|
"@rjsf/validator-ajv8": "^5.24.1",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.11.7",
|
||||||
|
"@tiptap/extension-table": "^2.11.7",
|
||||||
|
"@tiptap/extension-table-cell": "^2.11.7",
|
||||||
|
"@tiptap/extension-table-header": "^2.11.7",
|
||||||
|
"@tiptap/extension-table-row": "^2.11.7",
|
||||||
|
"@tiptap/extension-text-align": "^2.11.7",
|
||||||
|
"@tiptap/extension-underline": "^2.11.7",
|
||||||
|
"@tiptap/react": "^2.11.7",
|
||||||
|
"@tiptap/starter-kit": "^2.11.7",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"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",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"react-error-boundary": "^6.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": {
|
||||||
|
|||||||
164
gui/rpk-gui/public/locales/deDE/common.json
Normal file
164
gui/rpk-gui/public/locales/deDE/common.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"login": {
|
||||||
|
"title": "Melden Sie sich bei Ihrem Konto an",
|
||||||
|
"signin": "Einloggen",
|
||||||
|
"signup": "Anmelden",
|
||||||
|
"divider": "oder",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Passwort"
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"google": "Einloggen mit Google",
|
||||||
|
"discord": "Einloggen mit Discord"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Ungültige E-Mail-Adresse",
|
||||||
|
"requiredEmail": "E-Mail ist erforderlich",
|
||||||
|
"requiredPassword": "Passwort wird benötigt"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Anmeldung",
|
||||||
|
"forgotPassword": "Passwort vergessen?",
|
||||||
|
"noAccount": "Sie haben kein Konto?",
|
||||||
|
"rememberMe": "Erinnere dich an mich"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Haben Sie Ihr Passwort vergessen?",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Ungültige E-Mail-Adresse",
|
||||||
|
"requiredEmail": "E-Mail ist erforderlich"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Anweisungen zum Zurücksetzen senden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Registrieren Sie sich für Ihr Konto",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Passwort"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Ungültige E-Mail-Adresse",
|
||||||
|
"requiredEmail": "E-Mail ist erforderlich",
|
||||||
|
"requiredPassword": "Passwort wird benötigt"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Registrieren",
|
||||||
|
"haveAccount": "Ein Konto haben?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatePassword": {
|
||||||
|
"title": "Kennwort aktualisieren",
|
||||||
|
"fields": {
|
||||||
|
"password": "Neues Passwort",
|
||||||
|
"confirmPassword": "Bestätige neues Passwort"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"confirmPasswordNotMatch": "Passwörter stimmen nicht überein",
|
||||||
|
"requiredPassword": "Passwort wird benötigt",
|
||||||
|
"requiredConfirmPassword": "Das Feld „Passwort bestätigen“ ist erforderlich"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Aktualisieren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"info": "Sie haben vergessen, {{action}} component zu {{resource}} hinzufügen.",
|
||||||
|
"404": "Leider existiert diese Seite nicht.",
|
||||||
|
"resource404": "Haben Sie die {{resource}} resource erstellt?",
|
||||||
|
"backHome": "Zurück"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"list": "Aufführen",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"show": "Zeigen"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"create": "Erstellen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"confirm": "Sicher?",
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"refresh": "Erneuern",
|
||||||
|
"show": "Zeigen",
|
||||||
|
"undo": "Undo",
|
||||||
|
"import": "Importieren",
|
||||||
|
"clone": "Klon",
|
||||||
|
"notAccessTitle": "Sie haben keine zugriffsberechtigung"
|
||||||
|
},
|
||||||
|
"warnWhenUnsavedChanges": "Nicht gespeicherte Änderungen werden nicht übernommen.",
|
||||||
|
"notifications": {
|
||||||
|
"success": "Erfolg",
|
||||||
|
"error": "Fehler (status code: {{statusCode}})",
|
||||||
|
"undoable": "Sie haben {{seconds}} Sekunden Zeit für Undo.",
|
||||||
|
"createSuccess": "{{resource}} erfolgreich erstellt.",
|
||||||
|
"createError": "Fehler beim Erstellen {{resource}} (status code: {{statusCode}})",
|
||||||
|
"deleteSuccess": "{{resource}} erfolgreich gelöscht.",
|
||||||
|
"deleteError": "Fehler beim Löschen {{resource}} (status code: {{statusCode}})",
|
||||||
|
"editSuccess": "{{resource}} erfolgreich bearbeitet.",
|
||||||
|
"editError": "Fehler beim Bearbeiten {{resource}} (status code: {{statusCode}})",
|
||||||
|
"importProgress": "{{processed}}/{{total}} importiert"
|
||||||
|
},
|
||||||
|
"loading": "Wird geladen",
|
||||||
|
"tags": {
|
||||||
|
"clone": "Klon"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard"
|
||||||
|
},
|
||||||
|
"posts": {
|
||||||
|
"posts": "Einträge",
|
||||||
|
"fields": {
|
||||||
|
"id": "Id",
|
||||||
|
"title": "Titel",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
|
"published": "Veröffentlicht",
|
||||||
|
"draft": "Draft",
|
||||||
|
"rejected": "Abgelehnt"
|
||||||
|
},
|
||||||
|
"content": "Inhalh",
|
||||||
|
"createdAt": "Erstellt am"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"create": "Erstellen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"list": "Einträge",
|
||||||
|
"show": "Eintrag zeigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
|
"documentTitle": {
|
||||||
|
"default": "refine",
|
||||||
|
"suffix": " | Refine",
|
||||||
|
"post": {
|
||||||
|
"list": "Beiträge | Refine",
|
||||||
|
"show": "#{{id}} Beitrag anzeigen | Refine",
|
||||||
|
"edit": "#{{id}} Beitrag bearbeiten | Refine",
|
||||||
|
"create": "Neuen Beitrag erstellen | Refine",
|
||||||
|
"clone": "#{{id}} Beitrag klonen | Refine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoSave": {
|
||||||
|
"success": "gespeichert",
|
||||||
|
"error": "fehler beim automatischen speichern",
|
||||||
|
"loading": "speichern...",
|
||||||
|
"idle": "warten auf anderungen"
|
||||||
|
}
|
||||||
|
}
|
||||||
284
gui/rpk-gui/public/locales/enUS/common.json
Normal file
284
gui/rpk-gui/public/locales/enUS/common.json
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"home": {
|
||||||
|
"title": "Home"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to your account",
|
||||||
|
"signin": "Sign in",
|
||||||
|
"signup": "Sign up",
|
||||||
|
"divider": "or",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"google": "Sign in with Google",
|
||||||
|
"discord": "Sign in with Discord"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Invalid email address",
|
||||||
|
"requiredEmail": "Email is required",
|
||||||
|
"requiredPassword": "Password is required"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Login",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"noAccount": "Don’t have an account?",
|
||||||
|
"rememberMe": "Remember me"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Forgot your password?",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Invalid email address",
|
||||||
|
"requiredEmail": "Email is required"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Send reset instructions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Sign up for your account",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Invalid email address",
|
||||||
|
"requiredEmail": "Email is required",
|
||||||
|
"requiredPassword": "Password is required"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Register",
|
||||||
|
"haveAccount": "Have an account?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatePassword": {
|
||||||
|
"title": "Update password",
|
||||||
|
"fields": {
|
||||||
|
"password": "New Password",
|
||||||
|
"confirmPassword": "Confirm new password"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"confirmPasswordNotMatch": "Passwords do not match",
|
||||||
|
"requiredPassword": "Password required",
|
||||||
|
"requiredConfirmPassword": "Confirm password is required"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Update"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"404": "Sorry, the page you visited does not exist.",
|
||||||
|
"info": "You may have forgotten to add the {{action}} component to {{resource}} resource.",
|
||||||
|
"resource404": "Are you sure you have created the {{resource}} resource.",
|
||||||
|
"backHome": "Back Home"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"list": "List",
|
||||||
|
"create": "Create",
|
||||||
|
"edit": "Edit",
|
||||||
|
"show": "Show"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save",
|
||||||
|
"logout": "Logout",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Are you sure?",
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear": "Clear",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"show": "Show",
|
||||||
|
"undo": "Undo",
|
||||||
|
"import": "Import",
|
||||||
|
"clone": "Clone",
|
||||||
|
"notAccessTitle": "You don't have permission to access"
|
||||||
|
},
|
||||||
|
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||||
|
"notifications": {
|
||||||
|
"success": "Successful",
|
||||||
|
"error": "Error (status code: {{statusCode}})",
|
||||||
|
"undoable": "You have {{seconds}} seconds to undo",
|
||||||
|
"createSuccess": "Successfully created {{resource}}",
|
||||||
|
"createError": "There was an error creating {{resource}} (status code: {{statusCode}})",
|
||||||
|
"deleteSuccess": "Successfully deleted {{resource}}",
|
||||||
|
"deleteError": "Error when deleting {{resource}} (status code: {{statusCode}})",
|
||||||
|
"editSuccess": "Successfully edited {{resource}}",
|
||||||
|
"editError": "Error when editing {{resource}} (status code: {{statusCode}})",
|
||||||
|
"importProgress": "Importing: {{processed}}/{{total}}"
|
||||||
|
},
|
||||||
|
"loading": "Loading",
|
||||||
|
"tags": {
|
||||||
|
"clone": "Clone"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard"
|
||||||
|
},
|
||||||
|
"posts": {
|
||||||
|
"posts": "Posts",
|
||||||
|
"fields": {
|
||||||
|
"id": "Id",
|
||||||
|
"title": "Title",
|
||||||
|
"category": "Category",
|
||||||
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
|
"published": "Published",
|
||||||
|
"draft": "Draft",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"content": "Content",
|
||||||
|
"createdAt": "Created At"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"create": "Create Post",
|
||||||
|
"edit": "Edit Post",
|
||||||
|
"list": "Posts",
|
||||||
|
"show": "Show Post"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"documentTitle": {
|
||||||
|
"default": "refine",
|
||||||
|
"suffix": " | Refine",
|
||||||
|
"post": {
|
||||||
|
"list": "Posts | Refine",
|
||||||
|
"show": "#{{id}} Show Post | Refine",
|
||||||
|
"edit": "#{{id}} Edit Post | Refine",
|
||||||
|
"create": "Create new Post | Refine",
|
||||||
|
"clone": "#{{id}} Clone Post | Refine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoSave": {
|
||||||
|
"success": "saved",
|
||||||
|
"error": "auto save failure",
|
||||||
|
"loading": "saving...",
|
||||||
|
"idle": "waiting for changes"
|
||||||
|
},
|
||||||
|
"undefined": {
|
||||||
|
"undefined": "No translation",
|
||||||
|
"titles": {
|
||||||
|
"list": "No translation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"created_by": "Created by",
|
||||||
|
"created_at": "Created at",
|
||||||
|
"updated_by": "Updated by",
|
||||||
|
"updated_at": "Updated at",
|
||||||
|
"label": "Label",
|
||||||
|
"type": "Type",
|
||||||
|
"individual": {
|
||||||
|
"type": "Individual",
|
||||||
|
"lastname": "Lastname",
|
||||||
|
"surnames": "Surname",
|
||||||
|
"day_of_birth": "Date of birth",
|
||||||
|
"firstname": "Firstname",
|
||||||
|
"place_of_birth": "Place of birth",
|
||||||
|
"middlename": "Middlename",
|
||||||
|
"resource_title": "Individual"
|
||||||
|
},
|
||||||
|
"corporation": {
|
||||||
|
"type": "Corporation",
|
||||||
|
"activity": "Activity",
|
||||||
|
"title": "Title",
|
||||||
|
"employees": "Employees",
|
||||||
|
"resource_title": "Corporation"
|
||||||
|
},
|
||||||
|
"employee": {
|
||||||
|
"position": "Position",
|
||||||
|
"entity_id": "Identity",
|
||||||
|
"resource_title": "Employee"
|
||||||
|
},
|
||||||
|
"institution": {
|
||||||
|
"type": "Institution",
|
||||||
|
"title": "Title",
|
||||||
|
"activity": "Activity",
|
||||||
|
"employees": "Employees",
|
||||||
|
"resource_title": "Institution"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"entity_data": "Informations",
|
||||||
|
"address": "Address",
|
||||||
|
"resource_title": "Entity",
|
||||||
|
"resource_plural": "Entities"
|
||||||
|
},
|
||||||
|
"provision_template": {
|
||||||
|
"name": "Name",
|
||||||
|
"title": "Title",
|
||||||
|
"body": "Body",
|
||||||
|
"resource_title": "Provision Template",
|
||||||
|
"resource_plural": "Provision Templates"
|
||||||
|
},
|
||||||
|
"contract_template": {
|
||||||
|
"name": "Name",
|
||||||
|
"title": "Title",
|
||||||
|
"provisions": "Provisions",
|
||||||
|
"parties": "Parties",
|
||||||
|
"variables": "Variables",
|
||||||
|
"resource_title": "Contract Template",
|
||||||
|
"resource_plural": "Contract Templates"
|
||||||
|
},
|
||||||
|
"party_template": {
|
||||||
|
"entity_id": "Entity",
|
||||||
|
"representative_id": "Representative",
|
||||||
|
"part": "Part",
|
||||||
|
"resource_title": "Party"
|
||||||
|
},
|
||||||
|
"provision_template_reference": {
|
||||||
|
"provision_template_id": "Provision Template",
|
||||||
|
"resource_title": "Provision Template"
|
||||||
|
},
|
||||||
|
"dictionary_entry": {
|
||||||
|
"key": "Variable",
|
||||||
|
"value": "Value",
|
||||||
|
"resource_title": "Variables"
|
||||||
|
},
|
||||||
|
"contract_draft": {
|
||||||
|
"name": "Name",
|
||||||
|
"title": "Title",
|
||||||
|
"parties": "Parties",
|
||||||
|
"provisions": "Provisions",
|
||||||
|
"variables": "Variables",
|
||||||
|
"resource_title": "Contract Draft",
|
||||||
|
"resource_plural": "Contract Drafts"
|
||||||
|
},
|
||||||
|
"draft_party": {
|
||||||
|
"entity_id": "Client",
|
||||||
|
"part": "Part",
|
||||||
|
"representative_id": "Representative",
|
||||||
|
"resource_title": "Party"
|
||||||
|
},
|
||||||
|
"contract_provision_template_reference": {
|
||||||
|
"provision_template_id": "Provision Template",
|
||||||
|
"type": "Provision Template",
|
||||||
|
"resource_title": "Provision Template"
|
||||||
|
},
|
||||||
|
"provision_genuine": {
|
||||||
|
"title": "Title",
|
||||||
|
"body": "Body",
|
||||||
|
"type": "Genuine Provision",
|
||||||
|
"resource_title": "Genuine Provision"
|
||||||
|
},
|
||||||
|
"draft_provision": {
|
||||||
|
"provision": "Provision",
|
||||||
|
"resource_title": "Provision"
|
||||||
|
},
|
||||||
|
"contract": {
|
||||||
|
"date": "Date",
|
||||||
|
"location": "Location",
|
||||||
|
"resource_title": "Contract",
|
||||||
|
"resource_plural": "Contracts",
|
||||||
|
"draft_id": "Draft"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
284
gui/rpk-gui/public/locales/frFR/common.json
Normal file
284
gui/rpk-gui/public/locales/frFR/common.json
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"home": {
|
||||||
|
"title": "Page d'accueil"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Authentification",
|
||||||
|
"signin": "S'authentifier",
|
||||||
|
"signup": "Créer un compte",
|
||||||
|
"divider": "ou",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Mot de passe"
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"google": "S'authentifier avec Google",
|
||||||
|
"discord": "S'authentifier avec Discord"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Email invalide",
|
||||||
|
"requiredEmail": "l'Email est obligatoire",
|
||||||
|
"requiredPassword": "Le mot de passe est obligatoire"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "S'authentifier",
|
||||||
|
"forgotPassword": "Mot de passe oublié?",
|
||||||
|
"noAccount": "Vous n'avec pas de compte?",
|
||||||
|
"rememberMe": "Se souvenir de moi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Mot de passe oublié?",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "mail invalide",
|
||||||
|
"requiredEmail": "l'Email est obligatoire"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Envoyer les instructions de récupération"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Création de compte",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Mot de passe"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"validEmail": "Email invalide",
|
||||||
|
"requiredEmail": "l'Email est obligatoire",
|
||||||
|
"requiredPassword": "Le mot de passe est obligatoire"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Créer un compte",
|
||||||
|
"haveAccount": "Vous avez déjà un compte?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatePassword": {
|
||||||
|
"title": "Mise à jour du mot de passe",
|
||||||
|
"fields": {
|
||||||
|
"password": "Nouveau mot de passe",
|
||||||
|
"confirmPassword": "Confirmation"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"confirmPasswordNotMatch": "Les mots de passe ne correspondent pas",
|
||||||
|
"requiredPassword": "Le mot de passe est obligatoire",
|
||||||
|
"requiredConfirmPassword": "Vous devez confirmer votre mot de passe"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"submit": "Mettre à jour"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"404": "Cette page n'existe pas.",
|
||||||
|
"info": "Il manque l'action {{action}} component à la ressource {{resource}} .",
|
||||||
|
"resource404": "Cette page n'existe pas.",
|
||||||
|
"backHome": "Retour à l'accueil"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"list": "Liste",
|
||||||
|
"create": "Création",
|
||||||
|
"edit": "Édtion",
|
||||||
|
"show": "Voir"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"create": "Créer",
|
||||||
|
"save": "Sauvegarder",
|
||||||
|
"logout": "Déconnexion",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Êtes vous sur?",
|
||||||
|
"filter": "Filtrer",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"refresh": "Rafraîchir",
|
||||||
|
"show": "Voir",
|
||||||
|
"undo": "Annuler",
|
||||||
|
"import": "Importer",
|
||||||
|
"clone": "Cloner",
|
||||||
|
"notAccessTitle": "Vous n'avez pas la permission d'accéder à cette ressource"
|
||||||
|
},
|
||||||
|
"warnWhenUnsavedChanges": "Êtes vous sur de vouloir quitter la page? Vous avez des modification non sauvegardées.",
|
||||||
|
"notifications": {
|
||||||
|
"success": "Succès",
|
||||||
|
"error": "Erreur (Code de statut: {{statusCode}})",
|
||||||
|
"undoable": "Vous avez {{seconds}} secondes à annuler",
|
||||||
|
"createSuccess": "Création de {{resource}} réussie",
|
||||||
|
"createError": "Erreur pendant la création de {{resource}} (Code de statut: {{statusCode}})",
|
||||||
|
"deleteSuccess": "Suppression de {{resource}} réussie",
|
||||||
|
"deleteError": "Erreur pendant la suppression de {{resource}} (Code de statut: {{statusCode}})",
|
||||||
|
"editSuccess": "Modification de {{resource}} réussie",
|
||||||
|
"editError": "Erreur pendant la modification de {{resource}} (Code de statut: {{statusCode}})",
|
||||||
|
"importProgress": "Importation de: {{processed}}/{{total}}"
|
||||||
|
},
|
||||||
|
"loading": "Chargement",
|
||||||
|
"tags": {
|
||||||
|
"clone": "Clone"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Tableau de bord"
|
||||||
|
},
|
||||||
|
"posts": {
|
||||||
|
"posts": "Posts",
|
||||||
|
"fields": {
|
||||||
|
"id": "Id",
|
||||||
|
"title": "Title",
|
||||||
|
"category": "Category",
|
||||||
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
|
"published": "Published",
|
||||||
|
"draft": "Draft",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"content": "Content",
|
||||||
|
"createdAt": "Created At"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"create": "Create Post",
|
||||||
|
"edit": "Edit Post",
|
||||||
|
"list": "Posts",
|
||||||
|
"show": "Show Post"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"documentTitle": {
|
||||||
|
"default": "refine",
|
||||||
|
"suffix": " | Refine",
|
||||||
|
"post": {
|
||||||
|
"list": "Posts | Refine",
|
||||||
|
"show": "#{{id}} Show Post | Refine",
|
||||||
|
"edit": "#{{id}} Edit Post | Refine",
|
||||||
|
"create": "Create new Post | Refine",
|
||||||
|
"clone": "#{{id}} Clone Post | Refine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoSave": {
|
||||||
|
"success": "Sauvegardé",
|
||||||
|
"error": "Sauvegarde automatique ratée",
|
||||||
|
"loading": "Sauvegarde...",
|
||||||
|
"idle": "En attente de modification"
|
||||||
|
},
|
||||||
|
"undefined": {
|
||||||
|
"undefined": "No translation",
|
||||||
|
"titles": {
|
||||||
|
"list": "No translation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"created_by": "Créé par",
|
||||||
|
"created_at": "Créé le",
|
||||||
|
"updated_by": "Modifié par",
|
||||||
|
"updated_at": "Modifié le",
|
||||||
|
"label": "Label",
|
||||||
|
"type": "Type",
|
||||||
|
"individual": {
|
||||||
|
"type": "Particulier",
|
||||||
|
"middlename": "Autres prénoms",
|
||||||
|
"lastname": "Nom",
|
||||||
|
"firstname": "Prénom",
|
||||||
|
"day_of_birth": "Date de naissance",
|
||||||
|
"surnames": "Surnoms",
|
||||||
|
"place_of_birth": "Lieu de naissance",
|
||||||
|
"resource_title": "Particulier"
|
||||||
|
},
|
||||||
|
"corporation": {
|
||||||
|
"type": "Entreprise",
|
||||||
|
"title": "Dénomination sociale",
|
||||||
|
"activity": "Activité",
|
||||||
|
"employees": "Employés",
|
||||||
|
"resource_title": "Entreprise"
|
||||||
|
},
|
||||||
|
"employee": {
|
||||||
|
"entity_id": "Identité",
|
||||||
|
"position": "Poste",
|
||||||
|
"resource_title": "Employé"
|
||||||
|
},
|
||||||
|
"institution": {
|
||||||
|
"type": "Institution",
|
||||||
|
"title": "Titre",
|
||||||
|
"employees": "Employés",
|
||||||
|
"activity": "Activité",
|
||||||
|
"resource_title": "Institution"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"entity_data": "Informations",
|
||||||
|
"address": "Adresse",
|
||||||
|
"resource_title": "Entité",
|
||||||
|
"resource_plural": "Entités"
|
||||||
|
},
|
||||||
|
"provision_template": {
|
||||||
|
"name": "Nom",
|
||||||
|
"body": "Corps",
|
||||||
|
"title": "Titre",
|
||||||
|
"resource_title": "Template de Clause",
|
||||||
|
"resource_plural": "Templates de Clauses"
|
||||||
|
},
|
||||||
|
"contract_template": {
|
||||||
|
"name": "Nom",
|
||||||
|
"title": "Titre",
|
||||||
|
"parties": "Parties",
|
||||||
|
"provisions": "Clauses",
|
||||||
|
"variables": "Variables",
|
||||||
|
"resource_title": "Template de Contrat",
|
||||||
|
"resource_plural": "Templates de Contrats"
|
||||||
|
},
|
||||||
|
"party_template": {
|
||||||
|
"entity_id": "Entité",
|
||||||
|
"part": "Rôle",
|
||||||
|
"representative_id": "Représentant",
|
||||||
|
"resource_title": "Partie"
|
||||||
|
},
|
||||||
|
"provision_template_reference": {
|
||||||
|
"provision_template_id": "Template de clause",
|
||||||
|
"resource_title": "Template de clause"
|
||||||
|
},
|
||||||
|
"dictionary_entry": {
|
||||||
|
"key": "Variable",
|
||||||
|
"value": "Valeur",
|
||||||
|
"resource_title": "Variables"
|
||||||
|
},
|
||||||
|
"contract_draft": {
|
||||||
|
"name": "Nom",
|
||||||
|
"parties": "Parties",
|
||||||
|
"title": "Titre",
|
||||||
|
"provisions": "Clauses",
|
||||||
|
"variables": "Variables",
|
||||||
|
"resource_title": "Brouillon de Contrat",
|
||||||
|
"resource_plural": "Brouillons de Contrats"
|
||||||
|
},
|
||||||
|
"draft_party": {
|
||||||
|
"part": "Rôle",
|
||||||
|
"representative_id": "Représentant",
|
||||||
|
"entity_id": "Entité",
|
||||||
|
"resource_title": "Partie"
|
||||||
|
},
|
||||||
|
"contract_provision_template_reference": {
|
||||||
|
"type": "Template",
|
||||||
|
"provision_template_id": "Template de clause",
|
||||||
|
"resource_title": "Template de clause"
|
||||||
|
},
|
||||||
|
"provision_genuine": {
|
||||||
|
"type": "Personalisée",
|
||||||
|
"title": "Titre",
|
||||||
|
"body": "Corps",
|
||||||
|
"resource_title": "Clause personnalisée"
|
||||||
|
},
|
||||||
|
"draft_provision": {
|
||||||
|
"provision": "Clause",
|
||||||
|
"resource_title": "Clause"
|
||||||
|
},
|
||||||
|
"contract": {
|
||||||
|
"draft_id": "Brouillon",
|
||||||
|
"resource_title": "Contrat",
|
||||||
|
"resource_plural": "Contrats",
|
||||||
|
"location": "Lieu",
|
||||||
|
"date": "Date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import { Authenticated, Refine } from "@refinedev/core";
|
import { Authenticated, I18nProvider, Refine } from "@refinedev/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import { RefineSnackbarProvider, useNotificationProvider } from "@refinedev/mui";
|
||||||
RefineSnackbarProvider, RefineThemes,
|
|
||||||
useNotificationProvider,
|
|
||||||
} from "@refinedev/mui";
|
|
||||||
|
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import HistoryEduIcon from '@mui/icons-material/HistoryEdu';
|
||||||
import routerBindings, {
|
import routerBindings, { DocumentTitleHandler, UnsavedChangesNotifier } from "@refinedev/react-router";
|
||||||
CatchAllNavigate,
|
|
||||||
DocumentTitleHandler,
|
|
||||||
UnsavedChangesNotifier,
|
|
||||||
} from "@refinedev/react-router";
|
|
||||||
import dataProvider from "@refinedev/simple-rest";
|
|
||||||
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 { 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";
|
||||||
@@ -23,53 +17,84 @@ import { ForgotPassword } from "./components/auth/ForgotPassword";
|
|||||||
import { UpdatePassword } from "./components/auth/UpdatePassword";
|
import { UpdatePassword } from "./components/auth/UpdatePassword";
|
||||||
|
|
||||||
import { Header } from "./components";
|
import { Header } from "./components";
|
||||||
import { Hub } from "./pages/hub";
|
import { I18nTheme } from "./components/I18nTheme";
|
||||||
import { CreateFirm } from "./pages/hub/CreateFirm";
|
import { HubRoutes } from "./pages/hub";
|
||||||
|
import { FirmRoutes } from "./pages/firm";
|
||||||
|
|
||||||
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={RefineThemes.Blue}>
|
|
||||||
<ColorModeContextProvider>
|
<ColorModeContextProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
||||||
<RefineSnackbarProvider>
|
<RefineSnackbarProvider>
|
||||||
<Refine
|
<Refine
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
|
dataProvider={dataProvider}
|
||||||
|
i18nProvider={i18nProvider}
|
||||||
notificationProvider={useNotificationProvider}
|
notificationProvider={useNotificationProvider}
|
||||||
routerProvider={routerBindings}
|
routerProvider={routerBindings}
|
||||||
options={{
|
options={{
|
||||||
|
title: {
|
||||||
|
text: "Roleplay Contracts",
|
||||||
|
icon: <HistoryEduIcon />
|
||||||
|
},
|
||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
useNewQueryKeys: true,
|
useNewQueryKeys: true,
|
||||||
disableTelemetry: true
|
disableTelemetry: true,
|
||||||
|
reactQuery: {
|
||||||
|
clientConfig: {
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const status = error.statusCode ? error.statusCode : error.status
|
||||||
|
if (status >= 400 && status<= 499) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return failureCount < 4
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header />
|
<I18nTheme>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
element={(
|
element={(
|
||||||
<Authenticated key="authenticated-routes" redirectOnFail="/login" fallback={<CatchAllNavigate to="/login"/>}>
|
<Authenticated key="authenticated-routes" fallback={<Login />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Route path="/hub" element={ <Hub /> } />
|
<Route path="hub/*" element={<HubRoutes />} />
|
||||||
<Route path="/hub/create-firm" element={ <CreateFirm /> } />
|
<Route path="firm/*" element={<FirmRoutes />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route index element={<h1>HOME</h1>} />
|
<Route path="*" element={<Outlet />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="register" element={<Register />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/update-password" element={<UpdatePassword />} />
|
<Route path="update-password" element={<UpdatePassword />} />
|
||||||
|
</Route>
|
||||||
|
<Route index element={<><Header /><h1>{t("pages.home.title")}</h1></>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<UnsavedChangesNotifier />
|
<UnsavedChangesNotifier />
|
||||||
<DocumentTitleHandler />
|
<DocumentTitleHandler />
|
||||||
|
</I18nTheme>
|
||||||
</Refine>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
gui/rpk-gui/src/components/Cartouche.tsx
Normal file
40
gui/rpk-gui/src/components/Cartouche.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useTranslation } from "@refinedev/core";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { FirmContext } from "../contexts/FirmContext";
|
||||||
|
import Grid2 from '@mui/material/Grid2';
|
||||||
|
|
||||||
|
type CartoucheProps = {
|
||||||
|
record: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cartouche = (props: CartoucheProps) => {
|
||||||
|
const { record } = props;
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid2 container spacing={0}>
|
||||||
|
<Grid2 size={2}>{t("schemas.created_by")}:</Grid2>
|
||||||
|
<Grid2 size={4}><AuthorField partnerId={record.created_by} /></Grid2>
|
||||||
|
<Grid2 size={2}>{t("schemas.created_at")}:</Grid2>
|
||||||
|
<Grid2 size={4}>{new Date(record.created_at).toLocaleString()}</Grid2>
|
||||||
|
<Grid2 size={2}>{t("schemas.updated_by")}:</Grid2>
|
||||||
|
<Grid2 size={4}><AuthorField partnerId={record.updated_by} /></Grid2>
|
||||||
|
<Grid2 size={2}>{t("schemas.updated_at")}:</Grid2>
|
||||||
|
<Grid2 size={4}>{new Date(record.updated_at).toLocaleString()}</Grid2>
|
||||||
|
</Grid2>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Cartouche;
|
||||||
|
|
||||||
|
const AuthorField = (props: {partnerId: string})=> {
|
||||||
|
const { partnerId } = props;
|
||||||
|
const { partnerMap } = useContext(FirmContext);
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
|
||||||
|
if (partnerMap && partnerMap.has(partnerId)) {
|
||||||
|
return <>{ partnerMap.get(partnerId) }</>
|
||||||
|
}
|
||||||
|
return <>{t("REDACTED")}</>
|
||||||
|
}
|
||||||
24
gui/rpk-gui/src/components/I18nTheme.tsx
Normal file
24
gui/rpk-gui/src/components/I18nTheme.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import { useTranslation } from "@refinedev/core";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import * as locales from '@mui/material/locale';
|
||||||
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
|
|
||||||
|
type SupportedLocales = keyof typeof locales;
|
||||||
|
|
||||||
|
export const I18nTheme: React.FC<PropsWithChildren> = ({ children }: PropsWithChildren) => {
|
||||||
|
const { getLocale } = useTranslation();
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
const locale = getLocale() || "en"
|
||||||
|
const themeWithLocale = createTheme(theme, locales[locale as SupportedLocales])
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={themeWithLocale}>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale.slice(0,2)} >
|
||||||
|
{ children }
|
||||||
|
</LocalizationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { useSearchParams, Navigate } from "react-router";
|
||||||
|
import { useTranslation } from "@refinedev/core";
|
||||||
import { AuthPage } from "@refinedev/mui";
|
import { AuthPage } from "@refinedev/mui";
|
||||||
|
|
||||||
import GoogleIcon from "@mui/icons-material/Google";
|
import GoogleIcon from "@mui/icons-material/Google";
|
||||||
import DiscordIcon from "../../components/DiscordIcon";
|
import DiscordIcon from "../../components/DiscordIcon";
|
||||||
import { useSearchParams, Navigate } from "react-router";
|
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
|
const { translate } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
if (searchParams.get("oauth") == "success") {
|
if (searchParams.get("oauth") == "success") {
|
||||||
const redirect_to = localStorage.getItem("redirect_after_login")
|
const redirect_to = localStorage.getItem("redirect_after_login")
|
||||||
@@ -16,14 +17,15 @@ export const Login = () => {
|
|||||||
<AuthPage
|
<AuthPage
|
||||||
type="login"
|
type="login"
|
||||||
formProps={{ defaultValues: { email: "test@test.te", password: "test" }, }}
|
formProps={{ defaultValues: { email: "test@test.te", password: "test" }, }}
|
||||||
|
rememberMe={false}
|
||||||
providers={[{
|
providers={[{
|
||||||
name: "google",
|
name: "google",
|
||||||
label: "Sign in with Google",
|
label: translate("pages.login.oauth.google"),
|
||||||
icon: (<GoogleIcon style={{ fontSize: 24, }} />),
|
icon: (<GoogleIcon style={{ fontSize: 24, }} />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "discord",
|
name: "discord",
|
||||||
label: "Sign in with Discord",
|
label: translate("pages.login.oauth.discord"),
|
||||||
icon: (<DiscordIcon style={{ fontSize: 24, }} />),
|
icon: (<DiscordIcon style={{ fontSize: 24, }} />),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {Navigate, useSearchParams} from "react-router";
|
import { Button } from "@mui/material";
|
||||||
import {AuthPage} from "@refinedev/mui";
|
|
||||||
import GoogleIcon from "@mui/icons-material/Google";
|
|
||||||
import DiscordIcon from "../DiscordIcon";
|
|
||||||
import { useLogout } from "@refinedev/core";
|
import { useLogout } from "@refinedev/core";
|
||||||
|
import { useTranslation } from "@refinedev/core";
|
||||||
|
|
||||||
export const Logout = () => {
|
export const Logout = () => {
|
||||||
|
const { translate } = useTranslation();
|
||||||
const { mutate: logout } = useLogout();
|
const { mutate: logout } = useLogout();
|
||||||
|
|
||||||
return <button onClick={() => logout()}>Logout</button>;
|
return <Button onClick={() => logout()} >{ translate("buttons.logout") }</Button>;
|
||||||
};
|
};
|
||||||
|
|||||||
41
gui/rpk-gui/src/components/header/I18nPicker.tsx
Normal file
41
gui/rpk-gui/src/components/header/I18nPicker.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTranslation as useRefineTranslation } from "@refinedev/core";
|
||||||
|
|
||||||
|
const I18nPicker = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { getLocale, changeLocale } = useRefineTranslation();
|
||||||
|
const currentLocale = getLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
value={currentLocale}
|
||||||
|
options={i18n.languages}
|
||||||
|
disableClearable={true}
|
||||||
|
renderInput={(params) => {
|
||||||
|
return <TextField {...params} label={ "Language" } variant="outlined" />
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
component="li"
|
||||||
|
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
|
||||||
|
{...optionProps}
|
||||||
|
>
|
||||||
|
{ option }
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
changeLocale(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default I18nPicker;
|
||||||
@@ -1,63 +1,104 @@
|
|||||||
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||||
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
|
import HubIcon from '@mui/icons-material/Hub';
|
||||||
|
import { Button, Menu, MenuItem } from "@mui/material";
|
||||||
import AppBar from "@mui/material/AppBar";
|
import AppBar from "@mui/material/AppBar";
|
||||||
import Avatar from "@mui/material/Avatar";
|
import Avatar from "@mui/material/Avatar";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
|
||||||
import { HamburgerMenu, RefineThemedLayoutV2HeaderProps } from "@refinedev/mui";
|
|
||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
|
import { HamburgerMenu, RefineThemedLayoutV2HeaderProps, ThemedTitleV2 } from "@refinedev/mui";
|
||||||
import { ColorModeContext } from "../../contexts/color-mode";
|
import { ColorModeContext } from "../../contexts/color-mode";
|
||||||
import {Logout} from "../auth/Logout";
|
import { FirmContext } from "../../contexts/FirmContext";
|
||||||
|
import { Logout } from "../auth/Logout";
|
||||||
type IUser = {
|
import { IUser } from "../../interfaces";
|
||||||
id: number;
|
import MuiLink from "@mui/material/Link";
|
||||||
email: string;
|
import I18nPicker from "./I18nPicker";
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
|
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
|
||||||
sticky = true,
|
sticky = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const collapsed = false;
|
||||||
const { mode, setMode } = useContext(ColorModeContext);
|
const { mode, setMode } = useContext(ColorModeContext);
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
const openUserMenu = Boolean(anchorEl);
|
||||||
|
const handleOpenUserMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
}
|
||||||
|
const handleCloseUserMenu = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position={sticky ? "sticky" : "relative"}>
|
<AppBar position={sticky ? "sticky" : "relative"}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
width="100%"
|
width="100%"
|
||||||
justifyContent="flex-end"
|
justifyContent="space-between"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<HamburgerMenu />
|
<HamburgerMenu />
|
||||||
|
{currentFirm && (
|
||||||
|
<MuiLink
|
||||||
|
to={`/firm/${currentFirm.instance}/${currentFirm.firm}`}
|
||||||
|
component={Link}
|
||||||
|
underline="none"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BusinessIcon height="24px" width="24px" color="primary" />
|
||||||
|
{!collapsed && (
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={700}
|
||||||
|
color="text.primary"
|
||||||
|
fontSize="inherit"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{currentFirm.instance} / {currentFirm.firm}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</MuiLink>
|
||||||
|
|
||||||
|
)}
|
||||||
|
{!currentFirm && (
|
||||||
|
<ThemedTitleV2 collapsed={collapsed}/>
|
||||||
|
)}
|
||||||
|
{(user?.email) && (
|
||||||
|
<Link to="/hub"><HubIcon /></Link>
|
||||||
|
)}
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
width="100%"
|
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<IconButton
|
{(user?.email) && (
|
||||||
color="inherit"
|
|
||||||
onClick={() => {
|
|
||||||
setMode();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
{(user?.avatar || user?.email) && (
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
gap="16px"
|
gap="16px"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
{user?.email && (
|
<Button
|
||||||
|
id="user-menu-button"
|
||||||
|
aria-controls={openUserMenu ? 'user-menu' : undefined}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={openUserMenu ? 'true' : undefined}
|
||||||
|
onClick={handleOpenUserMenu}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
display: {
|
display: {
|
||||||
@@ -68,12 +109,31 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
|
|||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
>
|
>
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
<Avatar src={"user?.avatar"} alt={user?.email} />
|
||||||
<Avatar src={user?.avatar} alt={user?.email} />
|
</Button>
|
||||||
<Logout />
|
<Menu
|
||||||
|
id="user-menu"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={openUserMenu}
|
||||||
|
onClose={handleCloseUserMenu}
|
||||||
|
slotProps={{
|
||||||
|
list:{ 'aria-labelledby': 'user-menu-button' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleCloseUserMenu}><Logout /></MenuItem>
|
||||||
|
<MenuItem onClick={setMode}>
|
||||||
|
<IconButton color="inherit">
|
||||||
|
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||||
|
</IconButton>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
{!user && (
|
||||||
|
<Link to="/login"><Button>Login</Button></Link>
|
||||||
|
)}
|
||||||
|
<I18nPicker />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
52
gui/rpk-gui/src/contexts/FirmContext.tsx
Normal file
52
gui/rpk-gui/src/contexts/FirmContext.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { createContext, PropsWithChildren } from 'react';
|
||||||
|
import { IFirm } from "../interfaces";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { useOne } from "@refinedev/core";
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { FirmInitForm } from "../pages/firm";
|
||||||
|
import { Header } from "../components";
|
||||||
|
|
||||||
|
type FirmContextType = {
|
||||||
|
currentFirm: IFirm,
|
||||||
|
partnerMap?: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FirmContext = createContext<FirmContextType>(
|
||||||
|
{} as FirmContextType
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const FirmContextProvider: React.FC<PropsWithChildren> = ({ children }: PropsWithChildren) => {
|
||||||
|
const { instance, firm } = useParams<IFirm>();
|
||||||
|
const { data, isError, error, isLoading } = useOne({resource: 'firm', id: `${instance}/${firm}/`, errorNotification: false});
|
||||||
|
|
||||||
|
if (instance === undefined || firm === undefined) {
|
||||||
|
throw({statusCode: 400});
|
||||||
|
}
|
||||||
|
const currentFirm: IFirm = { instance, firm }
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && error) {
|
||||||
|
if (error.statusCode == 405) {
|
||||||
|
return <><Header /><FirmInitForm currentFirm={currentFirm} /></>
|
||||||
|
}
|
||||||
|
if (error.statusCode == 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFirm.entity = data?.data.entity;
|
||||||
|
let value: FirmContextType = {
|
||||||
|
currentFirm: currentFirm,
|
||||||
|
partnerMap: new Map(data?.data.partner_list.map((item: any) => [item.id, item.label])),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FirmContext.Provider value={value} >
|
||||||
|
{ children }
|
||||||
|
</FirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
gui/rpk-gui/src/i18n.tsx
Normal file
21
gui/rpk-gui/src/i18n.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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: ["enUS", "frFR"],
|
||||||
|
backend: {
|
||||||
|
loadPath: "/locales/{{lng}}/{{ns}}.json", // "http/locales/{{lng}}/{{ns}}.json"
|
||||||
|
},
|
||||||
|
//saveMissing: true,
|
||||||
|
ns: ["common"],
|
||||||
|
defaultNS: "common",
|
||||||
|
fallbackLng: ["enUS", "frFR"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
16
gui/rpk-gui/src/interfaces/index.tsx
Normal file
16
gui/rpk-gui/src/interfaces/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
export type IFirm = {
|
||||||
|
instance: string,
|
||||||
|
firm: string
|
||||||
|
entity?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: number,
|
||||||
|
email: string,
|
||||||
|
firms: [IFirm],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IAuthUser = User;
|
||||||
|
|
||||||
|
export type IUser = User | null;
|
||||||
66
gui/rpk-gui/src/lib/LabelledOutlined.tsx
Normal file
66
gui/rpk-gui/src/lib/LabelledOutlined.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { InputLabel, styled } from "@mui/material";
|
||||||
|
import NotchedOutline from "@mui/material/OutlinedInput/NotchedOutline";
|
||||||
|
|
||||||
|
const DivRoot = styled("div")(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
marginTop: "8px",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DivContentWrapper = styled("div")(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DivContent = styled("div")(({ theme }) => ({
|
||||||
|
paddingTop: "1px",
|
||||||
|
paddingLeft: theme.spacing(1),
|
||||||
|
paddingRight: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputLabel = styled(InputLabel)(({ theme }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledNotchedOutline = styled(NotchedOutline)(({ theme }) => [
|
||||||
|
{
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
borderColor: theme.palette.grey["400"]
|
||||||
|
},
|
||||||
|
theme.applyStyles('dark', {
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
borderColor: theme.palette.grey["800"]
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LabelledOutlined({ id, label, children, className }: Props): ReactElement {
|
||||||
|
const labelRef = React.useRef(null);
|
||||||
|
return (
|
||||||
|
<DivRoot className={clsx(className)}>
|
||||||
|
<StyledInputLabel
|
||||||
|
ref={labelRef}
|
||||||
|
htmlFor={id}
|
||||||
|
variant="outlined"
|
||||||
|
shrink
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</StyledInputLabel>
|
||||||
|
<DivContentWrapper>
|
||||||
|
<DivContent id={id}>
|
||||||
|
{children}
|
||||||
|
<StyledNotchedOutline notched label={label + "*"} />
|
||||||
|
</DivContent>
|
||||||
|
</DivContentWrapper>
|
||||||
|
</DivRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
gui/rpk-gui/src/lib/crud/components/base-form.tsx
Normal file
59
gui/rpk-gui/src/lib/crud/components/base-form.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import validator from "@rjsf/validator-ajv8";
|
||||||
|
import Form from "@rjsf/mui";
|
||||||
|
import { RegistryFieldsType, RegistryWidgetsType, RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||||
|
import CrudTextWidget from "./widgets/crud-text-widget";
|
||||||
|
import UnionEnumField from "./fields/union-enum";
|
||||||
|
import ArrayFieldTemplate from "./templates/ArrayFieldTemplate"
|
||||||
|
import ArrayFieldItemTemplate from "./templates/ArrayFieldItemTemplate";
|
||||||
|
import { ResourceContext } from "../contexts/ResourceContext";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ParametersContextProvider } from "../contexts/parameters-context";
|
||||||
|
import CrudArrayField from "./fields/crud-array-field";
|
||||||
|
|
||||||
|
type BaseFormProps = {
|
||||||
|
schema: RJSFSchema,
|
||||||
|
resourceBasePath: string,
|
||||||
|
onSubmit?: (data: any) => void,
|
||||||
|
onChange?: (data: any) => void,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
formData?: any,
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customWidgets: RegistryWidgetsType = {
|
||||||
|
TextWidget: CrudTextWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const customFields: RegistryFieldsType = {
|
||||||
|
AnyOfField: UnionEnumField,
|
||||||
|
ArrayField: CrudArrayField
|
||||||
|
}
|
||||||
|
|
||||||
|
const customTemplates = {
|
||||||
|
ArrayFieldTemplate,
|
||||||
|
ArrayFieldItemTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BaseForm: React.FC<BaseFormProps> = (props) => {
|
||||||
|
const { schema, uiSchema, resourceBasePath, formData, children, onSubmit, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceContext.Provider value={{basePath: resourceBasePath}} >
|
||||||
|
<ParametersContextProvider>
|
||||||
|
<Form
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema === undefined ? {} : uiSchema}
|
||||||
|
formData={formData}
|
||||||
|
onSubmit={(e, id) => onSubmit != undefined && onSubmit(e.formData)}
|
||||||
|
validator={validator}
|
||||||
|
omitExtraData={true}
|
||||||
|
widgets={customWidgets}
|
||||||
|
fields={customFields}
|
||||||
|
templates={customTemplates}
|
||||||
|
onChange={(e, id) => onChange != undefined && onChange(e.formData)}
|
||||||
|
children={children}
|
||||||
|
/>
|
||||||
|
</ParametersContextProvider>
|
||||||
|
</ResourceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
gui/rpk-gui/src/lib/crud/components/crud-filters.tsx
Normal file
73
gui/rpk-gui/src/lib/crud/components/crud-filters.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Accordion, AccordionDetails, AccordionSummary, CircularProgress } from "@mui/material";
|
||||||
|
import FilterForm from "../../filter-form/components/filter-form";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { GridExpandMoreIcon } from "@mui/x-data-grid";
|
||||||
|
import { useResourceFilter } from "../hook";
|
||||||
|
|
||||||
|
export type OnChangeValue = {
|
||||||
|
search: string | null
|
||||||
|
filters: {[filter: string]: {[op: string]: string}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrudFiltersProps = {
|
||||||
|
resourceName: string
|
||||||
|
resourcePath: string
|
||||||
|
onChange: (value: OnChangeValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CrudFilters = (props: CrudFiltersProps) => {
|
||||||
|
const { resourceName, resourcePath, onChange } = props
|
||||||
|
const { hasSearch, filtersSchema, filtersLoading } = useResourceFilter(resourceName, resourcePath)
|
||||||
|
|
||||||
|
if (filtersLoading) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentValue = {
|
||||||
|
search: "",
|
||||||
|
filters: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasSearch &&
|
||||||
|
<SearchFilter value="" onChange={(value) => {
|
||||||
|
currentValue.search = value;
|
||||||
|
onChange(currentValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<>Advanced filters<GridExpandMoreIcon /></>}
|
||||||
|
/>
|
||||||
|
<AccordionDetails>
|
||||||
|
<FilterForm fields={filtersSchema} values={{}} onChange={(value) => {
|
||||||
|
currentValue.filters = value;
|
||||||
|
onChange(currentValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CrudFilters;
|
||||||
|
|
||||||
|
type SearchFilter = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchFilter = (props: SearchFilter) => {
|
||||||
|
const {value, onChange} = props;
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="schemas.search"
|
||||||
|
fullWidth={true}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
defaultValue={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,65 +1,39 @@
|
|||||||
import validator from "@rjsf/validator-ajv8";
|
import { ReactNode } from "react";
|
||||||
import Form from "@rjsf/mui";
|
import { CircularProgress } from "@mui/material";
|
||||||
import { RegistryFieldsType, RegistryWidgetsType } from "@rjsf/utils";
|
import { UiSchema } from "@rjsf/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { BaseForm } from "./base-form";
|
||||||
import { jsonschemaProvider } from "../providers/jsonschema-provider";
|
import { useResourceSchema } from "../hook";
|
||||||
import { useForm } from "@refinedev/core";
|
|
||||||
import CrudTextWidget from "./widgets/crud-text-widget";
|
|
||||||
import UnionEnumField from "./fields/union-enum";
|
|
||||||
|
|
||||||
type Props = {
|
type CrudFormProps = {
|
||||||
schemaName: string,
|
schemaName: string,
|
||||||
resource: string,
|
uiSchema?: UiSchema,
|
||||||
id?: string,
|
record?: any,
|
||||||
//onSubmit: (data: IChangeEvent, event: FormEvent<any>) => void
|
resourceBasePath: string,
|
||||||
|
onSubmit?: (data: any) => void,
|
||||||
|
defaultValue?: any,
|
||||||
|
children?: ReactNode
|
||||||
|
card?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const customWidgets: RegistryWidgetsType = {
|
export const CrudForm: React.FC<CrudFormProps> = (props) => {
|
||||||
TextWidget: CrudTextWidget
|
const { schemaName, uiSchema, record, resourceBasePath, defaultValue, children, onSubmit=(data: any) => {}, card=false } = props;
|
||||||
};
|
const type = record === undefined ? "create" : card ? "card" : "update"
|
||||||
|
const { schema, schemaLoading } = useResourceSchema(schemaName, type);
|
||||||
|
|
||||||
const customFields: RegistryFieldsType = {
|
if(schemaLoading) {
|
||||||
AnyOfField: UnionEnumField
|
return <CircularProgress />
|
||||||
}
|
|
||||||
|
|
||||||
export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
|
|
||||||
const { onFinish, query, formLoading } = useForm({
|
|
||||||
resource: resource,
|
|
||||||
action: id === undefined ? "create" : "edit",
|
|
||||||
redirect: "show",
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const record = query?.data?.data;
|
|
||||||
const [formData, setFormData] = useState(record);
|
|
||||||
|
|
||||||
const [schema, setSchema] = useState({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSchema = async () => {
|
|
||||||
try {
|
|
||||||
const resourceSchema = await jsonschemaProvider.getResourceSchema(schemaName);
|
|
||||||
setSchema(resourceSchema);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching data:', error);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
fetchSchema();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<BaseForm
|
||||||
schema={schema}
|
schema={schema}
|
||||||
formData={record}
|
uiSchema={uiSchema}
|
||||||
onChange={(e) => setFormData(e.formData)}
|
formData={record || defaultValue}
|
||||||
onSubmit={(e) => onFinish(e.formData)}
|
resourceBasePath={resourceBasePath}
|
||||||
validator={validator}
|
onSubmit={
|
||||||
omitExtraData={true}
|
(data: any) => onSubmit(data)
|
||||||
widgets={customWidgets}
|
}
|
||||||
fields={customFields}
|
children={children}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef, GridColumnVisibilityModel, GridValidRowModel } from "@mui/x-data-grid";
|
||||||
|
import { UiSchema } from "@rjsf/utils";
|
||||||
|
import { useResourceColumns } from "../hook";
|
||||||
|
|
||||||
|
type CrudListProps = {
|
||||||
|
schemaName: string,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
columnDefinitions: ColumnDefinition[],
|
||||||
|
dataGridProps: any,
|
||||||
|
resourceBasePath: string,
|
||||||
|
onRowClick?: (params: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnSchema<T extends GridValidRowModel> = {
|
||||||
|
columns: GridColDef<T>[],
|
||||||
|
columnVisibilityModel: GridColumnVisibilityModel
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnDefinition = {
|
||||||
|
field: string,
|
||||||
|
column: Partial<GridColDef>,
|
||||||
|
hide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CrudList = <T extends GridValidRowModel>(props: CrudListProps) => {
|
||||||
|
const {
|
||||||
|
dataGridProps,
|
||||||
|
onRowClick,
|
||||||
|
schemaName,
|
||||||
|
columnDefinitions
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { columnSchema, columnLoading } = useResourceColumns<T>(schemaName, columnDefinitions);
|
||||||
|
|
||||||
|
if (columnLoading || columnSchema === undefined) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
columns={columnSchema.columns}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
pageSizeOptions={[10, 15, 25, 50, 100]}
|
||||||
|
disableColumnFilter={true}
|
||||||
|
initialState={{
|
||||||
|
columns: {
|
||||||
|
columnVisibilityModel: columnSchema.columnVisibilityModel
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CrudList;
|
||||||
|
|||||||
146
gui/rpk-gui/src/lib/crud/components/fields/crud-array-field.tsx
Normal file
146
gui/rpk-gui/src/lib/crud/components/fields/crud-array-field.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { JSONSchema7Definition } from "json-schema";
|
||||||
|
import {
|
||||||
|
FieldProps,
|
||||||
|
FormContextType,
|
||||||
|
getTemplate, getUiOptions,
|
||||||
|
RJSFSchema,
|
||||||
|
} from "@rjsf/utils";
|
||||||
|
import ArrayField from "@rjsf/core/lib/components/fields/ArrayField";
|
||||||
|
import validator from "@rjsf/validator-ajv8";
|
||||||
|
import Form from "@rjsf/mui";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Box, Paper } from "@mui/material";
|
||||||
|
import { CrudTextRJSFSchema } from "../widgets/crud-text-widget";
|
||||||
|
import { ParametersContext } from "../../contexts/parameters-context";
|
||||||
|
|
||||||
|
export type CrudArrayFieldSchema = RJSFSchema & {
|
||||||
|
props? : any
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const CrudArrayField = <T = any, S extends CrudArrayFieldSchema = CrudArrayFieldSchema, F extends FormContextType = any> (props: FieldProps<T[], S, F>)=> {
|
||||||
|
const { schema } = props
|
||||||
|
let isDictionary = false;
|
||||||
|
if (schema.props) {
|
||||||
|
if (schema.props.hasOwnProperty("display")) {
|
||||||
|
if (schema.props.display == "dictionary") {
|
||||||
|
isDictionary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isDictionary) {
|
||||||
|
return <Dictionary {...props} />
|
||||||
|
}
|
||||||
|
return <ArrayField<T,S,F> {...props}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CrudArrayField;
|
||||||
|
|
||||||
|
type DictionaryEntry = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dictionary = <
|
||||||
|
T = any,
|
||||||
|
S extends CrudTextRJSFSchema = CrudTextRJSFSchema,
|
||||||
|
F extends FormContextType = any
|
||||||
|
>(props: FieldProps<T[], S, F>)=> {
|
||||||
|
const { required, formData, onChange, registry, uiSchema, idSchema, schema } = props;
|
||||||
|
const uiOptions = getUiOptions<T[], S, F>(uiSchema);
|
||||||
|
const { parameters } = useContext(ParametersContext);
|
||||||
|
|
||||||
|
const ArrayFieldDescriptionTemplate = getTemplate<'ArrayFieldDescriptionTemplate', T[], S, F>(
|
||||||
|
'ArrayFieldDescriptionTemplate',
|
||||||
|
registry,
|
||||||
|
uiOptions
|
||||||
|
);
|
||||||
|
const ArrayFieldTitleTemplate = getTemplate<'ArrayFieldTitleTemplate', T[], S, F>(
|
||||||
|
'ArrayFieldTitleTemplate',
|
||||||
|
registry,
|
||||||
|
uiOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
let properties = new Set<string>()
|
||||||
|
for (const field in parameters) {
|
||||||
|
for (const param of parameters[field]) {
|
||||||
|
properties.add(param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: {[key:string]: string} = {}
|
||||||
|
if (formData !== undefined) {
|
||||||
|
for (const param of formData) {
|
||||||
|
// @ts-ignore
|
||||||
|
data[param.key] = param.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyDict = Object.values(data).length == 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={2}>
|
||||||
|
<Box p={2}>
|
||||||
|
<ArrayFieldTitleTemplate
|
||||||
|
idSchema={idSchema}
|
||||||
|
title={uiOptions.title || schema.title}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
required={required}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
<ArrayFieldDescriptionTemplate
|
||||||
|
idSchema={idSchema}
|
||||||
|
description={uiOptions.description || schema.description}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
{ emptyDict && <Typography>No variables found</Typography>}
|
||||||
|
{ !emptyDict && (
|
||||||
|
<Form
|
||||||
|
schema={getFormSchema(Array.from(properties.values()), required || false)}
|
||||||
|
tagName="div"
|
||||||
|
formData={data}
|
||||||
|
validator={validator}
|
||||||
|
omitExtraData={true}
|
||||||
|
onChange={(e, id) => {
|
||||||
|
console.log(e)
|
||||||
|
let value: T[] = []
|
||||||
|
for (const prop of properties) {
|
||||||
|
value.push({
|
||||||
|
key: prop,
|
||||||
|
value: e.formData.hasOwnProperty(prop) ? e.formData[prop] : undefined
|
||||||
|
} as T)
|
||||||
|
}
|
||||||
|
onChange(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormSchema(properties: string[], isRequired: boolean) {
|
||||||
|
const schema: JSONSchema7Definition = {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
let required: string[] = []
|
||||||
|
for (const pname of properties) {
|
||||||
|
schema.properties![pname] = {
|
||||||
|
type: "string",
|
||||||
|
title: pname
|
||||||
|
}
|
||||||
|
if (isRequired) {
|
||||||
|
required.push(pname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema.required = required
|
||||||
|
|
||||||
|
return schema
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid2 from '@mui/material/Grid2';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import { ArrayFieldTemplateItemType, FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils';
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
|
||||||
|
/** The `ArrayFieldItemTemplate` component is the template used to render an items of an array.
|
||||||
|
*
|
||||||
|
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||||
|
*/
|
||||||
|
export default function ArrayFieldItemTemplate<
|
||||||
|
T = any,
|
||||||
|
S extends StrictRJSFSchema = RJSFSchema,
|
||||||
|
F extends FormContextType = any
|
||||||
|
>(props: ArrayFieldTemplateItemType<T, S, F>) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
hasToolbar,
|
||||||
|
hasCopy,
|
||||||
|
hasMoveDown,
|
||||||
|
hasMoveUp,
|
||||||
|
hasRemove,
|
||||||
|
index,
|
||||||
|
onCopyIndexClick,
|
||||||
|
onDropIndexClick,
|
||||||
|
onReorderClick,
|
||||||
|
readonly,
|
||||||
|
uiSchema,
|
||||||
|
registry,
|
||||||
|
} = props;
|
||||||
|
const { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } = registry.templates.ButtonTemplates;
|
||||||
|
const btnStyle: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: 6,
|
||||||
|
paddingRight: 6,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayToolbar = hasToolbar && !props.readonly;
|
||||||
|
return (
|
||||||
|
<Grid2 container alignItems='center'>
|
||||||
|
<Grid2 style={{ overflow: 'auto' }} size={ displayToolbar ? 11 : 12}>
|
||||||
|
<Box mb={2}>
|
||||||
|
<Paper elevation={2}>
|
||||||
|
<Box p={2}>{children}</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
{displayToolbar && (
|
||||||
|
<Grid2 size={1}>
|
||||||
|
<Stack direction="column">
|
||||||
|
{(hasMoveUp || hasMoveDown) && (
|
||||||
|
<MoveUpButton
|
||||||
|
style={btnStyle}
|
||||||
|
disabled={disabled || readonly || !hasMoveUp}
|
||||||
|
onClick={onReorderClick(index, index - 1)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(hasMoveUp || hasMoveDown) && (
|
||||||
|
<MoveDownButton
|
||||||
|
style={btnStyle}
|
||||||
|
disabled={disabled || readonly || !hasMoveDown}
|
||||||
|
onClick={onReorderClick(index, index + 1)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasCopy && (
|
||||||
|
<CopyButton
|
||||||
|
style={btnStyle}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onClick={onCopyIndexClick(index)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasRemove && (
|
||||||
|
<RemoveButton
|
||||||
|
style={btnStyle}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onClick={onDropIndexClick(index)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
|
</Grid2>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid2 from '@mui/material/Grid2';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import {
|
||||||
|
getTemplate,
|
||||||
|
getUiOptions,
|
||||||
|
ArrayFieldTemplateProps,
|
||||||
|
ArrayFieldTemplateItemType,
|
||||||
|
FormContextType,
|
||||||
|
} from '@rjsf/utils';
|
||||||
|
import { CrudTextRJSFSchema } from "../widgets/crud-text-widget";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
/** The `ArrayFieldTemplate` component is the template used to render all items in an array.
|
||||||
|
*
|
||||||
|
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||||
|
*/
|
||||||
|
export default function ArrayFieldTemplate<
|
||||||
|
T = any,
|
||||||
|
S extends CrudTextRJSFSchema = CrudTextRJSFSchema,
|
||||||
|
F extends FormContextType = any
|
||||||
|
>(props: ArrayFieldTemplateProps<T, S, F>) {
|
||||||
|
const { canAdd, disabled, idSchema, uiSchema, items, onAddClick, readonly, registry, required, schema, title } =
|
||||||
|
props;
|
||||||
|
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||||
|
const ArrayFieldDescriptionTemplate = getTemplate<'ArrayFieldDescriptionTemplate', T, S, F>(
|
||||||
|
'ArrayFieldDescriptionTemplate',
|
||||||
|
registry,
|
||||||
|
uiOptions
|
||||||
|
);
|
||||||
|
const ArrayFieldItemTemplate = getTemplate<'ArrayFieldItemTemplate', T, S, F>(
|
||||||
|
'ArrayFieldItemTemplate',
|
||||||
|
registry,
|
||||||
|
uiOptions
|
||||||
|
);
|
||||||
|
const ArrayFieldTitleTemplate = getTemplate<'ArrayFieldTitleTemplate', T, S, F>(
|
||||||
|
'ArrayFieldTitleTemplate',
|
||||||
|
registry,
|
||||||
|
uiOptions
|
||||||
|
);
|
||||||
|
// Button templates are not overridden in the uiSchema
|
||||||
|
const {
|
||||||
|
ButtonTemplates: { AddButton },
|
||||||
|
} = registry.templates;
|
||||||
|
|
||||||
|
let gridSize = 12;
|
||||||
|
let numbered = false;
|
||||||
|
if (schema.props) {
|
||||||
|
if (schema.props.hasOwnProperty("items_per_row")) {
|
||||||
|
gridSize = gridSize / schema.props.items_per_row;
|
||||||
|
}
|
||||||
|
if (schema.props.hasOwnProperty("numbered")) {
|
||||||
|
numbered = schema.props.numbered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={2}>
|
||||||
|
<Box p={2}>
|
||||||
|
<ArrayFieldTitleTemplate
|
||||||
|
idSchema={idSchema}
|
||||||
|
title={uiOptions.title || title}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
required={required}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
<ArrayFieldDescriptionTemplate
|
||||||
|
idSchema={idSchema}
|
||||||
|
description={uiOptions.description || schema.description}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
<Grid2 container justifyContent='flex-start'>
|
||||||
|
{ items &&
|
||||||
|
items.map(({ key, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>, index) => (
|
||||||
|
<Grid2 key={key} size={gridSize} >
|
||||||
|
<Grid2 container sx={{alignItems: "center"}} >
|
||||||
|
{numbered &&<Grid2 size={.5} ><Typography variant="h4">{index + 1}</Typography></Grid2>}
|
||||||
|
<Grid2 size={numbered ? 11.5 : 12} ><ArrayFieldItemTemplate key={key} {...itemProps} /></Grid2>
|
||||||
|
</Grid2>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
{ canAdd && (
|
||||||
|
<Grid2 container justifyContent='flex-end'>
|
||||||
|
<Grid2>
|
||||||
|
<Box mt={2}>
|
||||||
|
<AddButton
|
||||||
|
className='array-item-add'
|
||||||
|
onClick={onAddClick}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,57 @@
|
|||||||
import {FormContextType, getTemplate, RJSFSchema, StrictRJSFSchema, WidgetProps} from "@rjsf/utils";
|
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 ForeignKeyWidget from "./foreign-key";
|
||||||
|
import RichtextWidget from "./richtext";
|
||||||
|
|
||||||
export default function CrudTextWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>(
|
export type CrudTextRJSFSchema = RJSFSchema & { props? : any };
|
||||||
|
|
||||||
|
export default function CrudTextWidget<T = any, S extends CrudTextRJSFSchema = CrudTextRJSFSchema, F extends FormContextType = any>(
|
||||||
props: WidgetProps<T, S, F>
|
props: WidgetProps<T, S, F>
|
||||||
) {
|
) {
|
||||||
if (props.schema.hasOwnProperty("foreign_key")) {
|
const { schema } = props;
|
||||||
return (<ForeignKeyWidget {...props} />);
|
if (schema.hasOwnProperty("foreignKey")) {
|
||||||
|
return <ForeignKeyWidget {...props} />;
|
||||||
|
} else if (schema.hasOwnProperty("const")) {
|
||||||
|
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 {
|
} else {
|
||||||
const { options, registry } = props;
|
const { widgets: { TextWidget } } = getDefaultRegistry<T,S,F>();
|
||||||
const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options);
|
return <TextWidget {...props} />;
|
||||||
return <BaseInputTemplate {...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>),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,79 +1,304 @@
|
|||||||
import {FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps} from '@rjsf/utils';
|
import { FormContextType, RJSFSchema, UiSchema, WidgetProps } from '@rjsf/utils';
|
||||||
import { Autocomplete } from "@mui/material";
|
import {
|
||||||
import { useState, useEffect } from "react";
|
Autocomplete, Button, CircularProgress, Container, Grid2, InputAdornment, Modal, TextField, Box, DialogContent
|
||||||
import TextField from "@mui/material/TextField";
|
} from "@mui/material";
|
||||||
import {BaseRecord, useList, useOne} from "@refinedev/core";
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import NoteAddIcon from '@mui/icons-material/NoteAdd';
|
||||||
|
import React, { useState, useEffect, useContext, Fragment } from "react";
|
||||||
|
import { useForm, useList, useOne } from "@refinedev/core";
|
||||||
|
import { ResourceContext } from "../../contexts/ResourceContext";
|
||||||
|
import { CrudForm } from "../crud-form";
|
||||||
|
import { ParametersContext } from "../../contexts/parameters-context";
|
||||||
|
|
||||||
type ForeignKeySchema = RJSFSchema & {
|
export type ForeignKeyReference = {
|
||||||
foreign_key?: {
|
|
||||||
reference: {
|
|
||||||
resource: string,
|
resource: string,
|
||||||
label: string
|
label?: string,
|
||||||
}
|
displayedFields?: [string],
|
||||||
|
schema: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForeignKeySchema = RJSFSchema & {
|
||||||
|
foreignKey?: {
|
||||||
|
reference: ForeignKeyReference
|
||||||
}
|
}
|
||||||
|
props? : any
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForeignKeyWidget<T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
|
export default function ForeignKeyWidget<T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
|
||||||
props: WidgetProps<T, S, F>
|
props: WidgetProps<T, S, F>
|
||||||
) {
|
) {
|
||||||
if (props.schema.foreign_key === undefined) {
|
if (props.schema.foreignKey === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resource = props.schema.foreign_key.reference.resource
|
|
||||||
const labelField = props.schema.foreign_key.reference.label
|
|
||||||
|
|
||||||
const valueResult = useOne({
|
const { value: originalValue, onChange } = props;
|
||||||
resource: resource,
|
const [currentValue, setCurrentValue] = useState(originalValue)
|
||||||
id: props.value != null ? props.value : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const empty_option: BaseRecord = {
|
if (currentValue) {
|
||||||
id: undefined
|
return <ChosenValue {...props} onClear={() => {setCurrentValue(null); onChange(null)}}/>
|
||||||
}
|
}
|
||||||
empty_option[labelField] = "(None)"
|
return <RealAutocomplete {...props} onChange={(value) => {setCurrentValue(value); onChange(value)}}/>
|
||||||
|
};
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState<string>("");
|
const RealAutocomplete = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
|
||||||
const [selectedValue, setSelectedValue] = useState(valueResult.data?.data || null);
|
props: WidgetProps<T, S, F>
|
||||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>(inputValue);
|
) => {
|
||||||
|
const { onChange, label, fieldId, schema } = props;
|
||||||
|
if (schema.foreignKey === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [openFormModal, setOpenFormModal] = useState(false);
|
||||||
|
const [searchString, setSearchString] = useState<string>("");
|
||||||
|
const [debouncedInputValue, setDebouncedInputValue] = useState<string>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => setDebouncedInputValue(inputValue), 300); // Adjust debounce delay as needed
|
const handler = setTimeout(() => setDebouncedInputValue(searchString), 300); // Adjust debounce delay as needed
|
||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [inputValue]);
|
}, [searchString]);
|
||||||
|
|
||||||
const listResult = useList({
|
const { setFieldParameters } = useContext(ParametersContext)
|
||||||
resource: resource,
|
useEffect(() => {
|
||||||
pagination: { current: 1, pageSize: 10 },
|
if (schema.hasOwnProperty("props") && schema.props.hasOwnProperty("parametrized") && schema.props.parametrized) {
|
||||||
filters: [{ field: "name", operator: "contains", value: debouncedInputValue }],
|
setFieldParameters(fieldId, [])
|
||||||
sorters: [{ field: "name", order: "asc" }],
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { resource, schema: fkSchema, label: labelField = "label" } = schema.foreignKey.reference
|
||||||
|
const { basePath } = useContext(ResourceContext)
|
||||||
|
const { data, isLoading } = useList({
|
||||||
|
resource: `${basePath}/${resource}`,
|
||||||
|
pagination: { current: 1, pageSize: 10, mode: "server" },
|
||||||
|
filters: [{ field: "search", operator: "contains", value: debouncedInputValue }],
|
||||||
|
sorters: [{ field: "label", order: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = listResult.data?.data || [];
|
|
||||||
if (! props.required) {
|
|
||||||
options.unshift(empty_option);
|
|
||||||
}
|
|
||||||
const isLoading = listResult.isLoading || valueResult.isLoading;
|
|
||||||
|
|
||||||
if(! selectedValue && valueResult.data) {
|
|
||||||
setSelectedValue(valueResult.data?.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
value={selectedValue}
|
onChange={(event, value) => {
|
||||||
onChange={(event, newValue) => {
|
onChange(value ? value.id : null);
|
||||||
setSelectedValue(newValue ? newValue : empty_option);
|
|
||||||
props.onChange(newValue ? newValue.id : null);
|
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
//inputValue={inputValue}
|
onInputChange={(event, value) => {
|
||||||
onInputChange={(event, newInputValue) => setInputValue(newInputValue)}
|
setSearchString(value)
|
||||||
options={options}
|
}}
|
||||||
|
options={data ? data.data : []}
|
||||||
getOptionLabel={(option) => option ? option[labelField] : ""}
|
getOptionLabel={(option) => option ? option[labelField] : ""}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
forcePopupIcon={false}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField {...params} label={ props.label } variant="outlined" />
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={ label }
|
||||||
|
variant="outlined"
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<Button variant="outlined" onClick={() => setOpenFormModal(true)} color="success" >
|
||||||
|
<NoteAddIcon />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Modal
|
||||||
|
open={openFormModal}
|
||||||
|
onClose={() => setOpenFormModal(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<FormContainerNew
|
||||||
|
schemaName={fkSchema}
|
||||||
|
resourceBasePath={basePath}
|
||||||
|
resource={resource}
|
||||||
|
uiSchema={{}}
|
||||||
|
onSuccess={(data: any) => {
|
||||||
|
setOpenFormModal(false)
|
||||||
|
onChange(data.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChosenValue = <T = any, S extends ForeignKeySchema = ForeignKeySchema, F extends FormContextType = any>(
|
||||||
|
props: WidgetProps<T, S, F> & { onClear: () => void }
|
||||||
|
) => {
|
||||||
|
const { onClear, value, schema, id: fieldId } = props;
|
||||||
|
|
||||||
|
const [openFormModal, setOpenFormModal] = React.useState(false);
|
||||||
|
|
||||||
|
if (props.schema.foreignKey === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { resource, schema: fkSchema, label: labelField = "label", displayedFields } = props.schema.foreignKey.reference
|
||||||
|
const { basePath } = useContext(ResourceContext)
|
||||||
|
|
||||||
|
const { data, isLoading, isSuccess } = useOne({
|
||||||
|
resource: `${basePath}/${resource}`,
|
||||||
|
id: value
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setFieldParameters } = useContext(ParametersContext)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess && schema.hasOwnProperty("props") && schema.props.hasOwnProperty("parametrized") && schema.props.parametrized) {
|
||||||
|
const record = data.data;
|
||||||
|
setFieldParameters(fieldId, extractParameters(record))
|
||||||
|
}
|
||||||
|
}, [isSuccess])
|
||||||
|
|
||||||
|
if (isLoading || data === undefined) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = data.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField label={ props.label } variant="outlined" disabled={true} value={record[labelField]}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Button variant="outlined" onClick={() => setOpenFormModal(true)} color="primary" >
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={onClear} color="error" >
|
||||||
|
<ClearIcon />
|
||||||
|
</Button>
|
||||||
|
</InputAdornment>),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ displayedFields && <Preview record={record} displayedFields={displayedFields}/>}
|
||||||
|
<Modal
|
||||||
|
open={openFormModal}
|
||||||
|
onClose={() => setOpenFormModal(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<FormContainerEdit
|
||||||
|
schemaName={fkSchema}
|
||||||
|
resourceBasePath={basePath}
|
||||||
|
resource={resource}
|
||||||
|
uiSchema={{}}
|
||||||
|
id={value}
|
||||||
|
onSuccess={() => setOpenFormModal(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 400,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '2px solid #000',
|
||||||
|
boxShadow: 24,
|
||||||
|
pt: 2,
|
||||||
|
px: 4,
|
||||||
|
pb: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FormContainerProps = {
|
||||||
|
schemaName: string,
|
||||||
|
resourceBasePath: string,
|
||||||
|
resource: string,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
id?: string,
|
||||||
|
onSuccess: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormContainerEdit = (props: FormContainerProps) => {
|
||||||
|
const { schemaName, resourceBasePath, resource, uiSchema = {}, id, onSuccess } = props;
|
||||||
|
const { onFinish, query, formLoading } = useForm({
|
||||||
|
resource: `${resourceBasePath}/${resource}`,
|
||||||
|
action: "edit",
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (formLoading || query?.data === undefined) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box sx={{ ...modalStyle, width: 800 }}>
|
||||||
|
<CrudForm
|
||||||
|
schemaName={schemaName}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
record={query.data.data}
|
||||||
|
onSubmit={(data:any) => {
|
||||||
|
onFinish(data);
|
||||||
|
onSuccess(data);
|
||||||
|
}} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormContainerNew = (props: FormContainerProps) => {
|
||||||
|
const { schemaName, resourceBasePath, resource, uiSchema = {}, onSuccess } = props;
|
||||||
|
const { onFinish } = useForm({
|
||||||
|
resource: `${resourceBasePath}/${resource}`,
|
||||||
|
action: "create",
|
||||||
|
onMutationSuccess: data => onSuccess(data.data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ ...modalStyle, width: 800 }}>
|
||||||
|
<CrudForm
|
||||||
|
schemaName={schemaName}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
onSubmit={(data:any) => { onFinish(data);}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Preview = (props: {record: any, displayedFields: [string]}) => {
|
||||||
|
const { record, displayedFields } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid2 container spacing={2}>
|
||||||
|
{displayedFields.map((field: string, index: number) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<Grid2 size={2}><Container>{field}</Container></Grid2>
|
||||||
|
<Grid2 size={9}><Container dangerouslySetInnerHTML={{ __html: record[field] }} ></Container></Grid2>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Grid2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractParameters = (obj: any)=> {
|
||||||
|
let result: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (typeof(obj[k]) == "string") {
|
||||||
|
const matches = obj[k].match(/%[^\s.]+%/g);
|
||||||
|
if (matches) {
|
||||||
|
const filtered = matches.map((p: string | any[]) => p.slice(1,-1)) as string[]
|
||||||
|
result = result.concat(filtered);
|
||||||
|
}
|
||||||
|
} else if (typeof(obj[k]) == "object") {
|
||||||
|
if (obj[k]) {
|
||||||
|
result = result.concat(extractParameters(obj[k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
|
type IndentOptions = {
|
||||||
|
/**
|
||||||
|
* @default ["paragraph", "heading"]
|
||||||
|
*/
|
||||||
|
types: string[];
|
||||||
|
/**
|
||||||
|
* Amount of margin to increase and decrease the indent
|
||||||
|
*
|
||||||
|
* @default 40
|
||||||
|
*/
|
||||||
|
margin: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
indent: {
|
||||||
|
indent: () => ReturnType;
|
||||||
|
outdent: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Extension.create<IndentOptions>({
|
||||||
|
name: "indent",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
types: ["paragraph", "heading"],
|
||||||
|
margin: 40
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
indent: {
|
||||||
|
default: 0,
|
||||||
|
renderHTML: (attrs) => ({
|
||||||
|
style: `margin-left: ${(attrs.indent || 0) * this.options.margin}px`
|
||||||
|
}),
|
||||||
|
parseHTML: (attrs) => parseInt(attrs.style.marginLeft) / this.options.margin || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
indent:
|
||||||
|
() =>
|
||||||
|
({ editor, chain, commands }) => {
|
||||||
|
// Check for a list
|
||||||
|
if (
|
||||||
|
editor.isActive("listItem") ||
|
||||||
|
editor.isActive("bulletList") ||
|
||||||
|
editor.isActive("orderedList")
|
||||||
|
) {
|
||||||
|
return chain().sinkListItem("listItem").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.options.types
|
||||||
|
.map((type) => {
|
||||||
|
const attrs = editor.getAttributes(type).indent;
|
||||||
|
const indent = (attrs || 0) + 1;
|
||||||
|
return commands.updateAttributes(type, { indent });
|
||||||
|
})
|
||||||
|
.every(Boolean);
|
||||||
|
},
|
||||||
|
outdent:
|
||||||
|
() =>
|
||||||
|
({ editor, chain, commands }) => {
|
||||||
|
// Check for a list
|
||||||
|
if (
|
||||||
|
editor.isActive("listItem") ||
|
||||||
|
editor.isActive("bulletList") ||
|
||||||
|
editor.isActive("orderedList")
|
||||||
|
) {
|
||||||
|
return chain().liftListItem("listItem").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.options.types
|
||||||
|
.filter((type) => {
|
||||||
|
const attrs = editor.getAttributes(type).indent;
|
||||||
|
return attrs > 0;
|
||||||
|
})
|
||||||
|
.map((type) => {
|
||||||
|
const attrs = editor.getAttributes(type).indent;
|
||||||
|
const indent = (attrs || 0) - 1;
|
||||||
|
return commands.updateAttributes(type, { indent });
|
||||||
|
});
|
||||||
|
return result.every(Boolean) && result.length > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// addKeyboardShortcuts() {
|
||||||
|
// return {
|
||||||
|
// Tab: ({ editor }) => {
|
||||||
|
// return editor.commands.indent();
|
||||||
|
// },
|
||||||
|
// "Shift-Tab": ({ editor }) => {
|
||||||
|
// return editor.commands.outdent();
|
||||||
|
// },
|
||||||
|
// Backspace: ({ editor }) => {
|
||||||
|
// const { selection } = editor.state;
|
||||||
|
//
|
||||||
|
// // Make sure we are at the start of the node
|
||||||
|
// if (selection.$anchor.parentOffset > 0 || selection.from !== selection.to) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return editor.commands.outdent();
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import FormatIndentIncrease from "@mui/icons-material/FormatIndentIncrease";
|
||||||
|
import { MenuButton, MenuButtonProps, useRichTextEditorContext } from "mui-tiptap";
|
||||||
|
|
||||||
|
|
||||||
|
export type MenuButtonIndentProps = Partial<MenuButtonProps>;
|
||||||
|
|
||||||
|
export default function MenuButtonIndent(props: MenuButtonIndentProps) {
|
||||||
|
const editor = useRichTextEditorContext();
|
||||||
|
return (
|
||||||
|
<MenuButton
|
||||||
|
tooltipLabel="Indent"
|
||||||
|
tooltipShortcutKeys={["Tab"]}
|
||||||
|
IconComponent={FormatIndentIncrease}
|
||||||
|
disabled={!editor?.isEditable || (!editor.can().indent())}
|
||||||
|
onClick={() => editor?.chain().focus().indent().run()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import FormatIndentDecrease from "@mui/icons-material/FormatIndentDecrease";
|
||||||
|
import { MenuButton, MenuButtonProps, useRichTextEditorContext } from "mui-tiptap";
|
||||||
|
|
||||||
|
export type MenuButtonUnindentProps = Partial<MenuButtonProps>;
|
||||||
|
|
||||||
|
export default function MenuButtonUnindent(props: MenuButtonUnindentProps) {
|
||||||
|
const editor = useRichTextEditorContext();
|
||||||
|
return (
|
||||||
|
<MenuButton
|
||||||
|
tooltipLabel="Unindent"
|
||||||
|
tooltipShortcutKeys={["Shift", "Tab"]}
|
||||||
|
IconComponent={FormatIndentDecrease}
|
||||||
|
disabled={!editor?.isEditable || !editor.can().outdent()}
|
||||||
|
onClick={() => editor?.chain().focus().outdent().run()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
gui/rpk-gui/src/lib/crud/components/widgets/richtext/index.tsx
Normal file
146
gui/rpk-gui/src/lib/crud/components/widgets/richtext/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { FormContextType, WidgetProps } from "@rjsf/utils";
|
||||||
|
import React from "react";
|
||||||
|
import { CrudTextRJSFSchema } from "../crud-text-widget";
|
||||||
|
import { useEditor, BubbleMenu } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Underline from "@tiptap/extension-underline"
|
||||||
|
import TextAlign from "@tiptap/extension-text-align"
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import {
|
||||||
|
MenuButtonAddTable, MenuButtonAlignCenter, MenuButtonAlignJustify, MenuButtonAlignLeft, MenuButtonAlignRight,
|
||||||
|
MenuButtonBold, MenuButtonBulletedList, MenuButtonItalic, MenuButtonOrderedList, MenuButtonRedo, MenuButtonUnderline,
|
||||||
|
MenuButtonUndo, MenuControlsContainer, MenuDivider, RichTextEditorProvider, RichTextField, TableBubbleMenu,
|
||||||
|
TableImproved,
|
||||||
|
} from "mui-tiptap";
|
||||||
|
|
||||||
|
import { UseEditorOptions } from "@tiptap/react/src/useEditor";
|
||||||
|
import LabelledOutlined from "../../../../LabelledOutlined";
|
||||||
|
import MenuButtonUnindent from "./MenuButtonUnindent";
|
||||||
|
import MenuButtonIndent from "./MenuButtonIndent";
|
||||||
|
import IndentExtension from "./IndentExtension"
|
||||||
|
import { Container, Paper, styled } from "@mui/material";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
|
||||||
|
|
||||||
|
const LeftContainer = styled(Container)(({ theme }) => [{
|
||||||
|
width: "2cm",
|
||||||
|
borderLeft: "8px ridge black",
|
||||||
|
borderRight: "1px dashed grey",
|
||||||
|
padding: "0px !important",
|
||||||
|
margin: "0px !important"
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TextContainer = styled(Container)(({ theme }) => [{
|
||||||
|
maxWidth: "580px",
|
||||||
|
padding: "0px !important",
|
||||||
|
margin: "0px !important"
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RightContainer = styled(Container)(({ theme }) => [{
|
||||||
|
width: "2cm",
|
||||||
|
borderRight: "8px groove black",
|
||||||
|
borderLeft: "1px dashed grey",
|
||||||
|
padding: "0px !important",
|
||||||
|
margin: "0px !important"
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const StyledLabelledOutlined = styled(LabelledOutlined)(({ theme }) => [{
|
||||||
|
padding: "1px !important",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RichtextWidget = <T = any, S extends CrudTextRJSFSchema = CrudTextRJSFSchema, F extends FormContextType = any>(
|
||||||
|
props: WidgetProps<T, S, F>
|
||||||
|
) => {
|
||||||
|
const { schema, value, onChange, label, id, readonly } = props;
|
||||||
|
const isMultiline = schema.props.multiline === true;
|
||||||
|
|
||||||
|
let editorOptions: UseEditorOptions;
|
||||||
|
if (isMultiline) {
|
||||||
|
editorOptions = {
|
||||||
|
extensions: [StarterKit, Underline, TextAlign.configure({types: ['paragraph', "table"]}), TableImproved.configure({resizable: true}), TableRow, TableHeader, TableCell, IndentExtension],
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editorOptions = {
|
||||||
|
extensions: [StarterKit, Underline,],
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
let text = editor.getText();
|
||||||
|
if (text.includes("\n")) {
|
||||||
|
text = text.replace("\n", " ");
|
||||||
|
editor.commands.setContent(text);
|
||||||
|
}
|
||||||
|
onChange(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editorOptions.content = value
|
||||||
|
|
||||||
|
const editor = useEditor(editorOptions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledLabelledOutlined label={label} id={id}>
|
||||||
|
<Stack direction="row" spacing={0} sx={{justifyContent: "center", alignItems: "stretch"}}>
|
||||||
|
<LeftContainer> </LeftContainer>
|
||||||
|
<TextContainer>
|
||||||
|
<RichTextEditorProvider editor={editor}>
|
||||||
|
<TableBubbleMenu />
|
||||||
|
{!readonly && <RichTextField
|
||||||
|
controls={
|
||||||
|
<MenuControlsContainer>
|
||||||
|
{isMultiline ? multilineButtons : singlelineButtons}
|
||||||
|
</MenuControlsContainer>
|
||||||
|
}
|
||||||
|
variant="standard"
|
||||||
|
/>}
|
||||||
|
{readonly && <RichTextField variant="standard" disabled={true}/>}
|
||||||
|
</RichTextEditorProvider>
|
||||||
|
</TextContainer>
|
||||||
|
<RightContainer> </RightContainer>
|
||||||
|
</Stack>
|
||||||
|
</StyledLabelledOutlined>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichtextWidget
|
||||||
|
|
||||||
|
const singlelineButtons = (
|
||||||
|
<>
|
||||||
|
<MenuButtonUndo tabIndex={-1} />
|
||||||
|
<MenuButtonRedo tabIndex={-1} />
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuButtonBold tabIndex={-1} />
|
||||||
|
<MenuButtonItalic tabIndex={-1} />
|
||||||
|
<MenuButtonUnderline tabIndex={-1} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const multilineButtons = (
|
||||||
|
<>
|
||||||
|
<MenuButtonUndo tabIndex={-1} />
|
||||||
|
<MenuButtonRedo tabIndex={-1} />
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuButtonBold tabIndex={-1} />
|
||||||
|
<MenuButtonItalic tabIndex={-1} />
|
||||||
|
<MenuButtonUnderline tabIndex={-1} />
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuButtonAlignLeft tabIndex={-1} />
|
||||||
|
<MenuButtonAlignCenter tabIndex={-1} />
|
||||||
|
<MenuButtonAlignRight tabIndex={-1} />
|
||||||
|
<MenuButtonAlignJustify tabIndex={-1} />
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuButtonUnindent tabIndex={-1} />
|
||||||
|
<MenuButtonIndent tabIndex={-1} />
|
||||||
|
<MenuButtonBulletedList tabIndex={-1} />
|
||||||
|
<MenuButtonOrderedList tabIndex={-1} />
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuButtonAddTable tabIndex={-1} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
9
gui/rpk-gui/src/lib/crud/contexts/ResourceContext.tsx
Normal file
9
gui/rpk-gui/src/lib/crud/contexts/ResourceContext.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
type ResourceContextType = {
|
||||||
|
basePath: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceContext = createContext<ResourceContextType>(
|
||||||
|
{} as ResourceContextType
|
||||||
|
);
|
||||||
30
gui/rpk-gui/src/lib/crud/contexts/parameters-context.tsx
Normal file
30
gui/rpk-gui/src/lib/crud/contexts/parameters-context.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { createContext, PropsWithChildren, useState } from 'react';
|
||||||
|
|
||||||
|
type Parameters = {
|
||||||
|
[field: string]: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParametersContextType = {
|
||||||
|
parameters: Parameters,
|
||||||
|
setFieldParameters: (fieldName: string, parameterList: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParametersContext = createContext<ParametersContextType>(
|
||||||
|
{} as ParametersContextType
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ParametersContextProvider: React.FC<PropsWithChildren> = ({ children }: PropsWithChildren) => {
|
||||||
|
const [parameters, setParameters] = useState<Parameters>({});
|
||||||
|
|
||||||
|
function setFieldParameters(fieldName: string, parameterList: string[]) {
|
||||||
|
let params = structuredClone(parameters)
|
||||||
|
params[fieldName] = parameterList
|
||||||
|
setParameters(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParametersContext.Provider value={{ parameters, setFieldParameters }} >
|
||||||
|
{children}
|
||||||
|
</ParametersContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
gui/rpk-gui/src/lib/crud/hook/index.tsx
Normal file
109
gui/rpk-gui/src/lib/crud/hook/index.tsx
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,56 +1,319 @@
|
|||||||
import { JSONSchema7Definition } from "json-schema";
|
|
||||||
import { RJSFSchema } from '@rjsf/utils';
|
import { RJSFSchema } from '@rjsf/utils';
|
||||||
|
import i18n from '../../../i18n'
|
||||||
|
import { JSONSchema7Definition } from "json-schema";
|
||||||
|
import { GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { GridColType } from "@mui/x-data-grid/models/colDef/gridColType";
|
||||||
|
|
||||||
const API_URL = "/api/v1";
|
const API_URL = "/api/v1";
|
||||||
|
|
||||||
|
type CrudRJSFSchema = RJSFSchema & {
|
||||||
|
properties?: {
|
||||||
|
[key: string]: JSONSchema7Definition & {
|
||||||
|
readOnly?: boolean | undefined;
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta_fields = ["id", "label", "created_at", "created_by", "updated_at", "updated_by"]
|
||||||
|
|
||||||
export const jsonschemaProvider = {
|
export const jsonschemaProvider = {
|
||||||
getResourceSchema: async (resourceName: string): Promise<RJSFSchema> => {
|
getCardResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
return buildResource(await getJsonschema(), resourceName)
|
const updateSchema = await getResourceSchema(`${resourceName}Update`);
|
||||||
}
|
const readSchema = await getResourceSchema(`${resourceName}Read`);
|
||||||
};
|
|
||||||
|
|
||||||
let rawSchema: RJSFSchema;
|
for (let prop_name in readSchema.properties) {
|
||||||
const getJsonschema = async (): Promise<RJSFSchema> => {
|
if (meta_fields.indexOf(prop_name) > -1) {
|
||||||
if (rawSchema === undefined) {
|
delete readSchema.properties[prop_name];
|
||||||
const response = await fetch(`${API_URL}/openapi.json`,);
|
} else if (! updateSchema.hasOwnProperty(prop_name)) {
|
||||||
rawSchema = await response.json();
|
if (is_reference(readSchema.properties[prop_name])) {
|
||||||
|
let subresourceName = get_reference_name(readSchema.properties[prop_name]);
|
||||||
|
readSchema.components.schemas[subresourceName].readOnly = true;
|
||||||
|
} else {
|
||||||
|
readSchema.properties[prop_name].readOnly = true;
|
||||||
}
|
}
|
||||||
return rawSchema;
|
}
|
||||||
|
}
|
||||||
|
changePropertiesOrder(readSchema);
|
||||||
|
|
||||||
|
return readSchema
|
||||||
|
},
|
||||||
|
|
||||||
|
getReadOnlyResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
|
const updateSchema = await getResourceSchema(`${resourceName}Update`);
|
||||||
|
const readSchema = await getResourceSchema(`${resourceName}Read`);
|
||||||
|
|
||||||
|
for (let prop_name in readSchema.properties) {
|
||||||
|
if (updateSchema.hasOwnProperty(prop_name)) {
|
||||||
|
delete readSchema.properties[prop_name];
|
||||||
|
} else {
|
||||||
|
readSchema.properties[prop_name].readOnly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readSchema
|
||||||
|
},
|
||||||
|
|
||||||
|
getReadResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
|
return getResourceSchema(`${resourceName}Read`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
|
return getResourceSchema(`${resourceName}Update`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
|
return getResourceSchema(`${resourceName}Create`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getReadResourceColumns: async (resourceName: string): Promise<GridColDef[]> => {
|
||||||
|
return getColumns(`${resourceName}Read`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getListFilters: async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
|
||||||
|
return getFilters(resourceName, resourcePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasSearch: async (resourcePath: string): Promise<boolean> => {
|
||||||
|
return hasSearch(resourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => {
|
||||||
|
return buildResource(await getJsonschema(), resourceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getColumns = async (resourceName: string): Promise<GridColDef[]> => {
|
||||||
|
return buildColumns(await getJsonschema(), resourceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilters = async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
|
||||||
|
return buildFilters(await getJsonschema(), resourceName, resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathParameter = {
|
||||||
|
in: string,
|
||||||
|
name:string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathSchema = {
|
||||||
|
parameters: PathParameter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterField = {
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
type: string,
|
||||||
|
operators: { name: string, label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Operator = {
|
||||||
|
operator: string,
|
||||||
|
fieldName: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasSearch(resourcePath: string): Promise<boolean> {
|
||||||
|
const jst = new JsonSchemaTraverser(await getJsonschema());
|
||||||
|
const pathSchema = jst.getPath(resourcePath);
|
||||||
|
for (const param of pathSchema.parameters) {
|
||||||
|
if (param.name == "search") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilters(rawSchema: RJSFSchema, resourceName: string, resourcePath: string): FilterField[] {
|
||||||
|
const shortResourceName = shortenResourceName(resourceName);
|
||||||
|
const jst = new JsonSchemaTraverser(rawSchema);
|
||||||
|
const pathSchema = jst.getPath(resourcePath);
|
||||||
|
|
||||||
|
const seen: { [k: string]: number } = {};
|
||||||
|
let filters: FilterField[] = []
|
||||||
|
for (const param of pathSchema.parameters) {
|
||||||
|
if (param.name.indexOf("__") > -1) {
|
||||||
|
const { operator, fieldName } = processParamName(param)
|
||||||
|
if (! seen.hasOwnProperty(fieldName)) {
|
||||||
|
seen[fieldName] = filters.length;
|
||||||
|
const field = jst.getPropertyByPath(jst.getResource(`${resourceName}Read`), fieldName)
|
||||||
|
filters.push({
|
||||||
|
name: fieldName,
|
||||||
|
label: getPropertyI18nLabel(shortResourceName, fieldName),
|
||||||
|
type: getFieldFilterType(fieldName, field),
|
||||||
|
operators: [{ name: operator, label: operator }]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
filters[seen[fieldName]].operators?.push({ name: operator, label: operator });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processParamName(param: PathParameter): Operator {
|
||||||
|
const nameParts = param.name.split("__")
|
||||||
|
|
||||||
|
return {
|
||||||
|
operator: nameParts.pop() as string,
|
||||||
|
fieldName: nameParts.join("."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFieldFilterType = (fieldName: string, field: RJSFSchema): string => {
|
||||||
|
if (fieldName == "created_by" || fieldName == "updated_by") {
|
||||||
|
return "author";
|
||||||
|
} else if (Array.isArray(field)) {
|
||||||
|
let enumValues = [];
|
||||||
|
for (const f of field) {
|
||||||
|
enumValues.push(f.const)
|
||||||
|
}
|
||||||
|
return `enum(${enumValues.join("|")})`
|
||||||
|
} else if (is_enum(field) && field.enum != undefined) {
|
||||||
|
return `enum(${field.enum.join("|")})`
|
||||||
|
} else if (field.hasOwnProperty('type')) {
|
||||||
|
if (field.type == "string" && field.format == "date-time") {
|
||||||
|
return "dateTime";
|
||||||
|
}
|
||||||
|
return field.type as string;
|
||||||
|
} else if (field.hasOwnProperty('anyOf') && field.anyOf) {
|
||||||
|
for (const prop of field.anyOf) {
|
||||||
|
if (typeof prop != "boolean" && prop.type != "null") {
|
||||||
|
return prop.type as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
throw "Unimplemented field type"
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: string|undefined = undefined): GridColDef[] {
|
||||||
|
const shortResourceName = shortenResourceName(resourceName);
|
||||||
|
const jst = new JsonSchemaTraverser(rawSchemas);
|
||||||
|
let resource = structuredClone(jst.getResource(resourceName));
|
||||||
|
|
||||||
|
let result: GridColDef[] = [];
|
||||||
|
if (is_enum(resource) && prefix !== undefined) {
|
||||||
|
return [{
|
||||||
|
field: prefix,
|
||||||
|
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prefix)}`) as string,
|
||||||
|
type: "string"
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
for (const prop_name in resource.properties) {
|
||||||
|
let prop = resource.properties[prop_name];
|
||||||
|
|
||||||
|
if (is_reference(prop)) {
|
||||||
|
const subresourceName = get_reference_name(prop);
|
||||||
|
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
|
||||||
|
} else if (is_union(prop)) {
|
||||||
|
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
|
||||||
|
let seen = new Set<string>();
|
||||||
|
for (let i in union) {
|
||||||
|
if (is_reference(union[i])) {
|
||||||
|
const subresourceName = get_reference_name(union[i]);
|
||||||
|
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
|
||||||
|
for (const s of subcolumns) {
|
||||||
|
if (! seen.has(s.field)) {
|
||||||
|
result.push(s);
|
||||||
|
seen.add(s.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (is_enum(prop)) {
|
||||||
|
let seen = new Set<string>();
|
||||||
|
for (let i in prop.allOf) {
|
||||||
|
if (is_reference(prop.allOf[i])) {
|
||||||
|
const subresourceName = get_reference_name(prop.allOf[i]);
|
||||||
|
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
|
||||||
|
for (const s of subcolumns) {
|
||||||
|
if (! seen.has(s.field)) {
|
||||||
|
result.push(s);
|
||||||
|
seen.add(s.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (is_array(prop) && is_reference(prop.items)) {
|
||||||
|
const subresourceName = get_reference_name(prop.items);
|
||||||
|
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
|
||||||
|
} else {
|
||||||
|
let valueGetter: undefined|((value: any, row: any) => any) = undefined;
|
||||||
|
let type: GridColType = "string";
|
||||||
|
if (is_array(prop)) {
|
||||||
|
valueGetter = (value: any[], row: any ) => {
|
||||||
|
return value.concat(".");
|
||||||
|
}
|
||||||
|
} else if (prefix !== undefined) {
|
||||||
|
valueGetter = (value: any, row: any ) => {
|
||||||
|
let parent = row;
|
||||||
|
for (const col of prefix.split(".")) {
|
||||||
|
parent = parent[col];
|
||||||
|
}
|
||||||
|
return parent ? parent[prop_name] : "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (prop.type == "string" && prop.format == "date-time") {
|
||||||
|
type = "dateTime"
|
||||||
|
valueGetter = (value: string) => new Date(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const column: GridColDef = {
|
||||||
|
field: prefix ? `${prefix}.${prop_name}` : prop_name,
|
||||||
|
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prop_name)}`, prop.title) as string,
|
||||||
|
type: type,
|
||||||
|
valueGetter: valueGetter
|
||||||
|
}
|
||||||
|
result.push(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
|
function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
|
||||||
let resource;
|
const shortResourceName = shortenResourceName(resourceName);
|
||||||
|
const jst = new JsonSchemaTraverser(rawSchemas);
|
||||||
|
let resource = structuredClone(jst.getResource(resourceName));
|
||||||
|
|
||||||
resource = structuredClone(rawSchemas.components.schemas[resourceName]);
|
|
||||||
resource.components = { schemas: {} };
|
resource.components = { schemas: {} };
|
||||||
for (let prop_name in resource.properties) {
|
for (let prop_name in resource.properties) {
|
||||||
let prop = resource.properties[prop_name];
|
let prop = resource.properties[prop_name];
|
||||||
|
|
||||||
if (is_reference(prop)) {
|
if (is_reference(prop)) {
|
||||||
resolveReference(rawSchemas, resource, prop);
|
buildReference(rawSchemas, resource, prop);
|
||||||
} else if (is_union(prop)) {
|
} else if (is_union(prop)) {
|
||||||
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
|
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
|
||||||
for (let i in union) {
|
for (let i in union) {
|
||||||
if (is_reference(union[i])) {
|
if (is_reference(union[i])) {
|
||||||
resolveReference(rawSchemas, resource, union[i]);
|
buildReference(rawSchemas, resource, union[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (is_enum(prop)) {
|
} else if (is_enum(prop)) {
|
||||||
for (let i in prop.allOf) {
|
for (let i in prop.allOf) {
|
||||||
if (is_reference(prop.allOf[i])) {
|
if (is_reference(prop.allOf[i])) {
|
||||||
resolveReference(rawSchemas, resource, prop.allOf[i]);
|
buildReference(rawSchemas, resource, prop.allOf[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (is_array(prop) && is_reference(prop.items)) {
|
} else if (is_array(prop) && is_reference(prop.items)) {
|
||||||
resolveReference(rawSchemas, resource, prop.items);
|
buildReference(rawSchemas, resource, prop.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prop.hasOwnProperty("title")) {
|
||||||
|
prop.title = i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prop_name)}`, prop.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resource.hasOwnProperty("title")) {
|
||||||
|
resource.title = i18n.t(`schemas.${shortResourceName}.resource_title`, resource.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resource;
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) {
|
function buildReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) {
|
||||||
const subresourceName = get_reference_name(prop_reference);
|
const subresourceName = get_reference_name(prop_reference);
|
||||||
const subresource = buildResource(rawSchemas, subresourceName);
|
const subresource = buildResource(rawSchemas, subresourceName);
|
||||||
resource.components.schemas[subresourceName] = subresource;
|
resource.components.schemas[subresourceName] = subresource;
|
||||||
@@ -63,23 +326,35 @@ function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference:
|
|||||||
|
|
||||||
function changePropertiesOrder(resource: any) {
|
function changePropertiesOrder(resource: any) {
|
||||||
let created_at;
|
let created_at;
|
||||||
|
let created_by;
|
||||||
let updated_at;
|
let updated_at;
|
||||||
|
let updated_by;
|
||||||
let new_properties: any = {};
|
let new_properties: any = {};
|
||||||
for (let prop_name in resource.properties) {
|
for (let prop_name in resource.properties) {
|
||||||
if (prop_name == 'created_at') {
|
if (prop_name == 'created_at') {
|
||||||
created_at = resource.properties[prop_name];
|
created_at = resource.properties[prop_name];
|
||||||
|
} else if (prop_name == 'created_by') {
|
||||||
|
created_by = resource.properties[prop_name];
|
||||||
} else if (prop_name == 'updated_at') {
|
} else if (prop_name == 'updated_at') {
|
||||||
updated_at = resource.properties[prop_name];
|
updated_at = resource.properties[prop_name];
|
||||||
} else {
|
} else if (prop_name == 'updated_by') {
|
||||||
|
updated_by = resource.properties[prop_name];
|
||||||
|
}else {
|
||||||
new_properties[prop_name] = resource.properties[prop_name];
|
new_properties[prop_name] = resource.properties[prop_name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (created_at) {
|
if (created_at) {
|
||||||
new_properties['created_at'] = created_at;
|
new_properties['created_at'] = created_at;
|
||||||
}
|
}
|
||||||
|
if (created_by) {
|
||||||
|
new_properties['created_by'] = created_by;
|
||||||
|
}
|
||||||
if (updated_at) {
|
if (updated_at) {
|
||||||
new_properties['updated_at'] = updated_at;
|
new_properties['updated_at'] = updated_at;
|
||||||
}
|
}
|
||||||
|
if (updated_by) {
|
||||||
|
new_properties['updated_by'] = updated_by;
|
||||||
|
}
|
||||||
resource.properties = new_properties
|
resource.properties = new_properties
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,82 +382,157 @@ function get_reference_name(prop: any) {
|
|||||||
return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1);
|
return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function has_descendant(rawSchemas:RJSFSchema, resource: RJSFSchema, property_name: string): boolean {
|
function convertCamelToSnake(str: string): string {
|
||||||
|
return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenResourceName(resourceName: string) {
|
||||||
|
return convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPropertyI18nLabel(shortResourceName: string, fieldName: string): string {
|
||||||
|
if (meta_fields.indexOf(fieldName) > -1) {
|
||||||
|
return i18n.t(`schemas.${convertCamelToSnake(fieldName)}`);
|
||||||
|
}
|
||||||
|
const path = `schemas.${shortResourceName}.${convertCamelToSnake(fieldName)}`
|
||||||
|
return i18n.t(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawSchema: RJSFSchema;
|
||||||
|
const getJsonschema = async (): Promise<RJSFSchema> => {
|
||||||
|
if (rawSchema === undefined) {
|
||||||
|
const response = await fetch(`${API_URL}/openapi.json`,);
|
||||||
|
rawSchema = await response.json();
|
||||||
|
}
|
||||||
|
return rawSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonSchemaTraverser = class {
|
||||||
|
private rawSchemas: RJSFSchema;
|
||||||
|
|
||||||
|
constructor(rawSchemas: RJSFSchema) {
|
||||||
|
this.rawSchemas = rawSchemas
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResource = (resourceName: string) => {
|
||||||
|
if (this.rawSchemas.components.schemas[resourceName] === undefined) {
|
||||||
|
throw new Error(`Resource "${resourceName}" not found in schema.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.rawSchemas.components.schemas[resourceName]
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPath = (resourcePath: string) => {
|
||||||
|
const resourceParts = `/${resourcePath}/`.split("/");
|
||||||
|
let pathSchema: PathSchema|undefined;
|
||||||
|
for (const path in this.rawSchemas.paths) {
|
||||||
|
if (this.rawSchemas.paths[path].hasOwnProperty("get")) {
|
||||||
|
const pathParts = path.split("/")
|
||||||
|
if (pathParts.length == resourceParts.length) {
|
||||||
|
for (let i = 0; i < pathParts.length; i++) {
|
||||||
|
const isVariable = pathParts[i].slice(0,1) == "{" && pathParts[i].slice(-1) == "}";
|
||||||
|
if (! isVariable && pathParts[i] != resourceParts[i] ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i == pathParts.length - 1) {
|
||||||
|
pathSchema = this.rawSchemas.paths[path].get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pathSchema !== undefined) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pathSchema === undefined) {
|
||||||
|
throw ("Path not found in schema");
|
||||||
|
}
|
||||||
|
return pathSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasDescendant = (resource: RJSFSchema, property_name: string): boolean => {
|
||||||
if (is_array(resource)) {
|
if (is_array(resource)) {
|
||||||
return property_name == 'items';
|
return property_name == 'items';
|
||||||
} else if (is_object(resource)) {
|
} else if (is_object(resource)) {
|
||||||
return property_name in resource.properties!;
|
return property_name in resource.properties!;
|
||||||
} else if (is_reference(resource)) {
|
} else if (is_reference(resource)) {
|
||||||
let subresourceName = get_reference_name(resource);
|
let subresourceName = get_reference_name(resource);
|
||||||
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
|
return this.hasDescendant(this.getResource(subresourceName), property_name);
|
||||||
} else if (is_union(resource)) {
|
} else if (is_union(resource)) {
|
||||||
const union = resource.hasOwnProperty("oneOf") ? resource.oneOf : resource.anyOf;
|
const union = resource.hasOwnProperty("oneOf") ? resource.oneOf : resource.anyOf;
|
||||||
if (union !== undefined) {
|
if (union !== undefined) {
|
||||||
for (const ref of union) {
|
for (const ref of union) {
|
||||||
return has_descendant(rawSchemas, ref as RJSFSchema, property_name)
|
return this.hasDescendant(ref as RJSFSchema, property_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (is_enum(resource)) {
|
} else if (is_enum(resource)) {
|
||||||
for (const ref of resource.allOf!) {
|
for (const ref of resource.allOf!) {
|
||||||
return has_descendant(rawSchemas, ref as RJSFSchema, property_name);
|
return this.hasDescendant(ref as RJSFSchema, property_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("Jsonschema format not implemented in property finder");
|
throw new Error("Jsonschema format not implemented in property finder");
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_descendant(rawSchemas: RJSFSchema, resource: RJSFSchema, property_name: string): RJSFSchema {
|
public getDescendant = (resource: RJSFSchema, property_name: string): RJSFSchema | RJSFSchema[] => {
|
||||||
if (is_array(resource) && property_name == 'items') {
|
if (is_array(resource) && property_name == 'items') {
|
||||||
return resource.items as RJSFSchema;
|
return resource.items as RJSFSchema;
|
||||||
} else if (is_object(resource) && resource.properties !== undefined && property_name in resource.properties!) {
|
} else if (is_object(resource) && resource.properties !== undefined && property_name in resource.properties!) {
|
||||||
return resource.properties[property_name] as RJSFSchema;
|
const prop = resource.properties[property_name];
|
||||||
|
if (is_reference(prop)) {
|
||||||
|
const subresourceName = get_reference_name(prop);
|
||||||
|
return this.getResource(subresourceName);
|
||||||
|
}
|
||||||
|
return prop as RJSFSchema;
|
||||||
} else if (is_reference(resource)) {
|
} else if (is_reference(resource)) {
|
||||||
let subresourceName = get_reference_name(resource);
|
let subresourceName = get_reference_name(resource);
|
||||||
let subresource = buildResource(rawSchemas, subresourceName);
|
let subresource = this.getResource(subresourceName);
|
||||||
return get_descendant(rawSchemas, subresource, property_name);
|
return this.getDescendant(subresource, property_name);
|
||||||
} else if (is_union(resource)) {
|
} else if (is_union(resource)) {
|
||||||
|
let descendants: RJSFSchema[] = [];
|
||||||
for (const ref of resource.oneOf!) {
|
for (const ref of resource.oneOf!) {
|
||||||
if (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) {
|
if (this.hasDescendant(ref as RJSFSchema, property_name)) {
|
||||||
return get_descendant(rawSchemas, ref as RJSFSchema, property_name);
|
descendants.push(this.getDescendant(ref as RJSFSchema, property_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (descendants.length > 0) {
|
||||||
|
return descendants;
|
||||||
|
}
|
||||||
} else if (is_enum(resource)) {
|
} else if (is_enum(resource)) {
|
||||||
for (const ref of resource.allOf!) {
|
for (const ref of resource.allOf!) {
|
||||||
if (has_descendant(rawSchemas, ref as RJSFSchema, property_name)) {
|
if (this.hasDescendant(ref as RJSFSchema, property_name)) {
|
||||||
return get_descendant(rawSchemas, ref as RJSFSchema, property_name);
|
return this.getDescendant(ref as RJSFSchema, property_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("property not found or Jsonschema format not implemented");
|
throw new Error("property not found or Jsonschema format not implemented");
|
||||||
}
|
|
||||||
|
|
||||||
function path_exists(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): boolean{
|
|
||||||
const pointFirstPosition = path.indexOf('.')
|
|
||||||
if (pointFirstPosition == -1) {
|
|
||||||
return has_descendant(rawSchemas, resource, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public pathExists = (resource: RJSFSchema, path: string): boolean => {
|
||||||
|
const pointFirstPosition = path.indexOf('.')
|
||||||
|
if (pointFirstPosition == -1) {
|
||||||
|
return this.hasDescendant(resource, path);
|
||||||
|
}
|
||||||
|
|
||||||
return has_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition))
|
return this.hasDescendant(resource, path.substring(0, pointFirstPosition))
|
||||||
&& path_exists(
|
&& this.pathExists(
|
||||||
rawSchemas,
|
this.getDescendant(resource, path.substring(0, pointFirstPosition)),
|
||||||
get_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition)),
|
|
||||||
path.substring(pointFirstPosition + 1)
|
path.substring(pointFirstPosition + 1)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function get_property_by_path(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): RJSFSchema {
|
|
||||||
const pointFirstPosition = path.indexOf('.')
|
|
||||||
if (pointFirstPosition == -1) {
|
|
||||||
return get_descendant(rawSchemas, resource, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return get_property_by_path(
|
public getPropertyByPath = (resource: RJSFSchema, path: string): RJSFSchema => {
|
||||||
rawSchemas,
|
const pointFirstPosition = path.indexOf('.')
|
||||||
get_descendant(
|
if (pointFirstPosition == -1) {
|
||||||
rawSchemas,
|
return this.getDescendant(resource, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPropertyByPath(
|
||||||
|
this.getDescendant(
|
||||||
resource,
|
resource,
|
||||||
path.substring(0, pointFirstPosition)
|
path.substring(0, pointFirstPosition)
|
||||||
),
|
),
|
||||||
path.substring(pointFirstPosition + 1)
|
path.substring(pointFirstPosition + 1)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
gui/rpk-gui/src/lib/filter-form/components/filter-form.tsx
Normal file
184
gui/rpk-gui/src/lib/filter-form/components/filter-form.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import { FirmContext } from "../../../contexts/FirmContext";
|
||||||
|
import { Fragment, useContext } from "react";
|
||||||
|
import { Box, Grid2, styled } from "@mui/material";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export type FilterField = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterFormProps = {
|
||||||
|
values: {[field_name: string]: { [operator: string]: string }}
|
||||||
|
fields: FilterField[]
|
||||||
|
onChange: (value: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterForm = (props: FilterFormProps) => {
|
||||||
|
const { fields, values, onChange } = props;
|
||||||
|
|
||||||
|
let currentValue = values
|
||||||
|
|
||||||
|
const formField = fields.filter(f => f.name != "search").map((f, index) =>
|
||||||
|
<Fragment key={`${f.name}-${index}`} >
|
||||||
|
{ f.name == "created_at" && <Box width="100%" /> }
|
||||||
|
<Grid2 size={6}>
|
||||||
|
<FilterFormField
|
||||||
|
field={f}
|
||||||
|
value={values.hasOwnProperty(f.name) ? values[f.name] : {}}
|
||||||
|
onChange={(value) => {
|
||||||
|
for (const op in value) {
|
||||||
|
if (value[op] == null || value[op] == "") {
|
||||||
|
if (currentValue.hasOwnProperty(f.name)) {
|
||||||
|
if (currentValue[f.name].hasOwnProperty(op)) {
|
||||||
|
delete currentValue[f.name][op];
|
||||||
|
}
|
||||||
|
if (Object.entries(currentValue[f.name]).length == 0) {
|
||||||
|
delete currentValue[f.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (! currentValue.hasOwnProperty(f.name)) {
|
||||||
|
currentValue[f.name] = {};
|
||||||
|
}
|
||||||
|
currentValue[f.name][op] = value[op];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(currentValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Grid2 container spacing={2}>
|
||||||
|
{formField}
|
||||||
|
</Grid2>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperatorValue = { [operator: string]: string }
|
||||||
|
|
||||||
|
type FilterFormFieldProps = {
|
||||||
|
field: FilterField
|
||||||
|
value: OperatorValue
|
||||||
|
onChange: (value: { [operator: string]: string | null }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterFormField = (props: FilterFormFieldProps) => {
|
||||||
|
const { field, value, onChange } = props;
|
||||||
|
|
||||||
|
if (field.type == "string") {
|
||||||
|
const defaultValue = value.hasOwnProperty('ilike') ? value['ilike'] : undefined
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
name={field.name}
|
||||||
|
label={field.label}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
fullWidth={true}
|
||||||
|
onChange={(event) => onChange({"ilike": event.target.value})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (field.type == "dateTime") {
|
||||||
|
return (
|
||||||
|
<FilterFieldDateRange field={field} value={value} onChange={onChange} />
|
||||||
|
)
|
||||||
|
} else if (field.type == "author") {
|
||||||
|
return (
|
||||||
|
<FilterFieldAuthor field={field} value={value} onChange={onChange} />
|
||||||
|
);
|
||||||
|
} else if (field.type.slice(0, 4) == "enum") {
|
||||||
|
const values = field.type.slice(5,-1).split("|");
|
||||||
|
const defaultValue = value.hasOwnProperty("in") ? [{
|
||||||
|
value: value["in"],
|
||||||
|
label: value["in"]
|
||||||
|
}] : undefined;
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={values.map(opt => {
|
||||||
|
return {
|
||||||
|
value: opt,
|
||||||
|
label: opt
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
onChange={(event, value) => onChange({ "in": value.map(v => v.value).join(",") })}
|
||||||
|
getOptionLabel={(option) => option.label}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={field.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw("Unsupported field filter type");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type FilterFieldAuthorProp = FilterFormFieldProps
|
||||||
|
|
||||||
|
const FilterFieldAuthor = (props: FilterFieldAuthorProp) => {
|
||||||
|
const { field, onChange } = props;
|
||||||
|
const { partnerMap } = useContext(FirmContext)
|
||||||
|
|
||||||
|
if (partnerMap == undefined) {
|
||||||
|
throw "Can't use author filter outside of the context of a firm";
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = []
|
||||||
|
for(let key of Array.from(partnerMap.keys()) ) {
|
||||||
|
options.push({
|
||||||
|
id: key,
|
||||||
|
label: partnerMap.get(key)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
renderInput={(params) => <TextField {...params} label={field.label} />}
|
||||||
|
options={options}
|
||||||
|
onChange={(event, value) => onChange({ "in": value.length == 0 ? null : value.map(v => v.id).join(",") })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterFieldDateRange = (props: FilterFormFieldProps) => {
|
||||||
|
const { field, value, onChange } = props;
|
||||||
|
|
||||||
|
const defaultAfterValue = value.hasOwnProperty('gte') ? dayjs(value['gte']) : undefined;
|
||||||
|
const defaultBeforeValue = value.hasOwnProperty('lte') ? dayjs(value['lte']) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row">
|
||||||
|
<DateTimePicker
|
||||||
|
name={field.name}
|
||||||
|
label={`${field.label} After:`}
|
||||||
|
slotProps={{ textField: { fullWidth: true }, field: { clearable: true }}}
|
||||||
|
defaultValue={defaultAfterValue}
|
||||||
|
onChange={(value) => onChange({'gte': value === null ? null : value.toJSON()})}
|
||||||
|
/>
|
||||||
|
<DateTimePicker
|
||||||
|
name={field.name}
|
||||||
|
label={`${field.label} Before:`}
|
||||||
|
slotProps={{ textField: { fullWidth: true }, field: { clearable: true } }}
|
||||||
|
defaultValue={defaultBeforeValue}
|
||||||
|
onChange={(value) => onChange({'lte': value === null ? null : value.toJSON()})}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterForm;
|
||||||
|
|
||||||
4
gui/rpk-gui/src/pages/ErrorPage.tsx
Normal file
4
gui/rpk-gui/src/pages/ErrorPage.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export const Error404Page = () => {
|
||||||
|
return <h2>EROR NO FUND</h2>
|
||||||
|
};
|
||||||
70
gui/rpk-gui/src/pages/firm/ContractRoutes.tsx
Normal file
70
gui/rpk-gui/src/pages/firm/ContractRoutes.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { Route, Routes, useParams } from "react-router";
|
||||||
|
import { useOne, useTranslation } from "@refinedev/core";
|
||||||
|
import { DeleteButton } from "@refinedev/mui";
|
||||||
|
import { CircularProgress, Stack } from "@mui/material";
|
||||||
|
import { CrudForm } from "../../lib/crud/components/crud-form";
|
||||||
|
import { FirmContext } from "../../contexts/FirmContext";
|
||||||
|
import List from "./base-page/List";
|
||||||
|
import Cartouche from "../../components/Cartouche";
|
||||||
|
|
||||||
|
export type Contract = {
|
||||||
|
id: string,
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContractRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <ListContract /> } />
|
||||||
|
<Route path="/edit/:record_id" element={ <EditContract /> } />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListContract = () => {
|
||||||
|
const columns = [
|
||||||
|
{ field: "label", column: { flex: 1 }},
|
||||||
|
{ field: "status", column: { width: 160 }},
|
||||||
|
{ field: "updated_at", column: { width: 160 }},
|
||||||
|
];
|
||||||
|
return <List<Contract> resource={`contracts`} schemaName={"Contract"} columns={columns} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditContract = () => {
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
|
||||||
|
const { record_id } = useParams();
|
||||||
|
|
||||||
|
const { data, isLoading } = useOne({resource: `${resourceBasePath}/contracts`, id: record_id,});
|
||||||
|
|
||||||
|
if (isLoading || data?.data === undefined) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = data.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>{record.label}</h2>
|
||||||
|
<Cartouche record={record}/>
|
||||||
|
<CrudForm
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
schemaName={"Contract"}
|
||||||
|
uiSchema={{"ui:readonly": true }}
|
||||||
|
record={record}
|
||||||
|
card={true}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
}}>
|
||||||
|
{ record.status == "published" && (<DeleteButton variant="contained" size="large" color="error" recordItemId={record_id}/>) }
|
||||||
|
</Stack>
|
||||||
|
</CrudForm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
gui/rpk-gui/src/pages/firm/DraftRoutes.tsx
Normal file
220
gui/rpk-gui/src/pages/firm/DraftRoutes.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
import { FirmContext } from "../../contexts/FirmContext";
|
||||||
|
import List from "./base-page/List";
|
||||||
|
import Edit from "./base-page/Edit";
|
||||||
|
import New from "./base-page/New";
|
||||||
|
import { Contract } from "./ContractRoutes";
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
id: string,
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <ListDraft /> } />
|
||||||
|
<Route path="/edit/:record_id" element={ <EditDraft /> } />
|
||||||
|
<Route path="/create" element={ <CreateDraft /> } />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListDraft = () => {
|
||||||
|
const columns = [
|
||||||
|
{ field: "label", column: { flex: 1 }},
|
||||||
|
{ field: "status", column: { width: 160 }},
|
||||||
|
{ field: "updated_at", column: { width: 160 }},
|
||||||
|
];
|
||||||
|
return <List<Draft> resource={`contracts/drafts`} columns={columns} schemaName={"Contract"} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditDraft = () => {
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
const { record_id } = useParams();
|
||||||
|
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
|
||||||
|
|
||||||
|
const { data, isLoading } = useOne({
|
||||||
|
resource: `${resourceBasePath}/contracts/drafts`,
|
||||||
|
id: record_id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record_id == undefined || !data?.data) {
|
||||||
|
return <Navigate to="../" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = data?.data
|
||||||
|
const readOnly = draft.status === "published";
|
||||||
|
|
||||||
|
const uiSchema = {
|
||||||
|
"ui:readonly": readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (draft.status === "published") {
|
||||||
|
return <h4>{t("resource.draft.already_published") }</h4>
|
||||||
|
}
|
||||||
|
if (draft.status === "in_progress") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4>{ t("resource.draft.todo") + ":" }</h4>
|
||||||
|
<ul>{ draft.todo.map((item: any) => <li>{ item }</li>) }</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <New<Contract>
|
||||||
|
resource={"contracts"}
|
||||||
|
schemaName={"Contract"}
|
||||||
|
defaultValue={{
|
||||||
|
date: dayjs().format("YYYY-MM-DD"),
|
||||||
|
location: "Los Santos, SA",
|
||||||
|
draft_id: draft.id
|
||||||
|
}}
|
||||||
|
uiSchema={{ draft_id: { 'ui:widget': 'hidden' } }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForeignKeySubSchema = ForeignKeySchema & {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</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 }/>
|
||||||
|
}
|
||||||
38
gui/rpk-gui/src/pages/firm/EntityRoutes.tsx
Normal file
38
gui/rpk-gui/src/pages/firm/EntityRoutes.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Route, Routes } from "react-router";
|
||||||
|
import List from "./base-page/List";
|
||||||
|
import Edit from "./base-page/Edit";
|
||||||
|
import New from "./base-page/New";
|
||||||
|
|
||||||
|
type Entity = {
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
entity_data: { type: string },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EntityRoutes = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <ListEntity /> } />
|
||||||
|
<Route path="/edit/:record_id" element={ <EditEntity /> } />
|
||||||
|
<Route path="/create" element={ <CreateEntity /> } />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListEntity = () => {
|
||||||
|
const columns = [
|
||||||
|
{ field: "entity_data.type", column: { width: 110 }},
|
||||||
|
{ field: "label", column: { flex: 1 }},
|
||||||
|
{ field: "updated_at", column: { width: 160 }},
|
||||||
|
];
|
||||||
|
return <List<Entity> resource={`entities`} schemaName={"Entity"} columns={columns} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditEntity = () => {
|
||||||
|
return <Edit<Entity> resource={`entities`} schemaName={"Entity"} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateEntity = () => {
|
||||||
|
return <New<Entity> resource={`entities`} schemaName={"Entity"} />
|
||||||
|
}
|
||||||
36
gui/rpk-gui/src/pages/firm/ProvisionRoutes.tsx
Normal file
36
gui/rpk-gui/src/pages/firm/ProvisionRoutes.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Route, Routes } from "react-router";
|
||||||
|
import List from "./base-page/List";
|
||||||
|
import Edit from "./base-page/Edit";
|
||||||
|
import New from "./base-page/New";
|
||||||
|
|
||||||
|
type Provision = {
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProvisionRoutes = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <ListProvision /> } />
|
||||||
|
<Route path="/edit/:record_id" element={ <EditProvision /> } />
|
||||||
|
<Route path="/create" element={ <CreateProvision /> } />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListProvision = () => {
|
||||||
|
const columns = [
|
||||||
|
{ field: "label", column: { flex: 1 }},
|
||||||
|
{ field: "updated_at", column: { width: 160 }},
|
||||||
|
];
|
||||||
|
return <List<Provision> resource={`templates/provisions`} schemaName={"ProvisionTemplate"} columns={columns} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditProvision = () => {
|
||||||
|
return <Edit<Provision> resource={`templates/provisions`} schemaName={"ProvisionTemplate"} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateProvision = () => {
|
||||||
|
return <New<Provision> resource={`templates/provisions`} schemaName={"ProvisionTemplate"} />
|
||||||
|
}
|
||||||
35
gui/rpk-gui/src/pages/firm/TemplateRoutes.tsx
Normal file
35
gui/rpk-gui/src/pages/firm/TemplateRoutes.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Route, Routes } from "react-router";
|
||||||
|
import List from "./base-page/List";
|
||||||
|
import Edit from "./base-page/Edit";
|
||||||
|
import New from "./base-page/New";
|
||||||
|
|
||||||
|
type Template = {
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <ListTemplate /> } />
|
||||||
|
<Route path="/edit/:record_id" element={ <EditTemplate /> } />
|
||||||
|
<Route path="/create" element={ <CreateTemplate /> } />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListTemplate = () => {
|
||||||
|
const columns = [
|
||||||
|
{ field: "label", column: { flex: 1 }},
|
||||||
|
{ field: "updated_at", column: { width: 160 }},
|
||||||
|
];
|
||||||
|
return <List<Template> resource={`templates/contracts`} schemaName={"ContractTemplate"} columns={columns} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditTemplate = () => {
|
||||||
|
return <Edit<Template> resource={`templates/contracts`} schemaName={"ContractTemplate"} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateTemplate = () => {
|
||||||
|
return <New<Template> resource={`templates/contracts`} schemaName={"ContractTemplate"} />
|
||||||
|
}
|
||||||
75
gui/rpk-gui/src/pages/firm/base-page/Edit.tsx
Normal file
75
gui/rpk-gui/src/pages/firm/base-page/Edit.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { UiSchema } from "@rjsf/utils";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { useParams, Navigate, Link } from "react-router";
|
||||||
|
import { Button, CircularProgress } from "@mui/material";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import { useForm, useTranslation } from "@refinedev/core";
|
||||||
|
import { DeleteButton } from "@refinedev/mui";
|
||||||
|
import { FirmContext } from "../../../contexts/FirmContext";
|
||||||
|
import { CrudForm } from "../../../lib/crud/components/crud-form";
|
||||||
|
import Cartouche from "../../../components/Cartouche";
|
||||||
|
|
||||||
|
type EditProps = {
|
||||||
|
resource: string,
|
||||||
|
schemaName: string,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Edit = <T,>(props: EditProps) => {
|
||||||
|
const { schemaName, resource, uiSchema } = props;
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
|
||||||
|
const { record_id } = useParams();
|
||||||
|
|
||||||
|
const { onFinish, query, formLoading } = useForm({
|
||||||
|
resource: `${resourceBasePath}/${resource}`,
|
||||||
|
action: "edit",
|
||||||
|
redirect: "show",
|
||||||
|
id: record_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (formLoading) {
|
||||||
|
return <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query?.data?.data) {
|
||||||
|
return <Navigate to="../" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.error?.status == 404) {
|
||||||
|
throw query.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = query.data.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to={"../"} >
|
||||||
|
<Button>{t("buttons.list")}</Button>
|
||||||
|
</Link>
|
||||||
|
<h2>{record.label}</h2>
|
||||||
|
<Cartouche record={record}/>
|
||||||
|
<CrudForm
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
schemaName={schemaName}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
record={record}
|
||||||
|
onSubmit={(data: any) => onFinish(data)}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}>
|
||||||
|
<Button type='submit' variant="contained" size="large"><SaveIcon />{t("buttons.save")}</Button>
|
||||||
|
<DeleteButton variant="contained" size="large" color="error" recordItemId={record_id}/>
|
||||||
|
</Stack>
|
||||||
|
</CrudForm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Edit;
|
||||||
83
gui/rpk-gui/src/pages/firm/base-page/List.tsx
Normal file
83
gui/rpk-gui/src/pages/firm/base-page/List.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router"
|
||||||
|
import { UiSchema } from "@rjsf/utils";
|
||||||
|
import { CrudFilter, useTranslation } from "@refinedev/core";
|
||||||
|
import { List as RefineList, useDataGrid } from "@refinedev/mui";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { GridColDef, GridValidRowModel } from "@mui/x-data-grid";
|
||||||
|
import { FirmContext } from "../../../contexts/FirmContext";
|
||||||
|
import CrudList from "../../../lib/crud/components/crud-list";
|
||||||
|
import CrudFilters, { OnChangeValue } from "../../../lib/crud/components/crud-filters";
|
||||||
|
import { CrudOperators } from "@refinedev/core/src/contexts/data/types";
|
||||||
|
|
||||||
|
type ListProps = {
|
||||||
|
resource: string,
|
||||||
|
columns: ColumnDefinition[],
|
||||||
|
schemaName: string,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnDefinition = {
|
||||||
|
field: string,
|
||||||
|
column: Partial<GridColDef>,
|
||||||
|
hide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const List = <T extends GridValidRowModel>(props: ListProps) => {
|
||||||
|
const { resource, columns, schemaName } = props;
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
|
||||||
|
|
||||||
|
const { dataGridProps, tableQuery, setFilters } = useDataGrid<T>({
|
||||||
|
resource: `${resourceBasePath}/${resource}`,
|
||||||
|
});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (tableQuery.error?.status == 404) {
|
||||||
|
throw tableQuery.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFilterChange = (value: OnChangeValue) => {
|
||||||
|
let newFilters: CrudFilter[] = []
|
||||||
|
if (value.search != null) {
|
||||||
|
newFilters.push({
|
||||||
|
field: "search",
|
||||||
|
operator: "eq",
|
||||||
|
value: value.search
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const filterName in value.filters) {
|
||||||
|
for (const operator in value.filters[filterName]) {
|
||||||
|
newFilters.push({
|
||||||
|
field: filterName,
|
||||||
|
operator: operator as Exclude<CrudOperators, "or" | "and">,
|
||||||
|
value: value.filters[filterName][operator]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFilters(newFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RefineList>
|
||||||
|
<Link to={"create"} >
|
||||||
|
<Button>{t("buttons.create")}</Button>
|
||||||
|
</Link>
|
||||||
|
<CrudFilters
|
||||||
|
resourceName={schemaName}
|
||||||
|
resourcePath={`${resourceBasePath}/${resource}`}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
<CrudList
|
||||||
|
dataGridProps={dataGridProps}
|
||||||
|
onRowClick={(params: any) => { navigate(`edit/${params.id}`) }}
|
||||||
|
schemaName={schemaName}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
columnDefinitions={columns}
|
||||||
|
/>
|
||||||
|
</RefineList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default List;
|
||||||
41
gui/rpk-gui/src/pages/firm/base-page/New.tsx
Normal file
41
gui/rpk-gui/src/pages/firm/base-page/New.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { UiSchema } from "@rjsf/utils";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { useForm, useTranslation } from "@refinedev/core";
|
||||||
|
import { CrudForm } from "../../../lib/crud/components/crud-form";
|
||||||
|
import { FirmContext } from "../../../contexts/FirmContext";
|
||||||
|
import SaveIcon from "@mui/icons-material/Save";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
|
||||||
|
type NewProps = {
|
||||||
|
resource: string,
|
||||||
|
schemaName: string,
|
||||||
|
uiSchema?: UiSchema,
|
||||||
|
defaultValue?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const New = <T,>(props: NewProps) => {
|
||||||
|
const { schemaName, resource, uiSchema, defaultValue } = props;
|
||||||
|
const { currentFirm } = useContext(FirmContext);
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
|
||||||
|
|
||||||
|
const { onFinish } = useForm({
|
||||||
|
resource: `${resourceBasePath}/${resource}`,
|
||||||
|
action: "create",
|
||||||
|
redirect: "show",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrudForm
|
||||||
|
schemaName={schemaName}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onSubmit={(data: any) => onFinish(data)}
|
||||||
|
>
|
||||||
|
<Button type='submit' variant="contained" size="large"><SaveIcon />{t("buttons.create")}</Button>
|
||||||
|
</CrudForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default New;
|
||||||
83
gui/rpk-gui/src/pages/firm/index.tsx
Normal file
83
gui/rpk-gui/src/pages/firm/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Route, Routes, Link } from "react-router";
|
||||||
|
import React from "react";
|
||||||
|
import { useForm, useTranslation } from "@refinedev/core";
|
||||||
|
import { FirmContextProvider } from "../../contexts/FirmContext";
|
||||||
|
import { Header } from "../../components";
|
||||||
|
import { CrudForm } from "../../lib/crud/components/crud-form";
|
||||||
|
import { IFirm } from "../../interfaces";
|
||||||
|
import { EntityRoutes } from "./EntityRoutes";
|
||||||
|
import { ContractRoutes } from "./ContractRoutes";
|
||||||
|
import { DraftRoutes } from "./DraftRoutes";
|
||||||
|
import { TemplateRoutes } from "./TemplateRoutes";
|
||||||
|
import { ProvisionRoutes } from "./ProvisionRoutes";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { Error404Page } from "../ErrorPage";
|
||||||
|
|
||||||
|
export const FirmRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/:instance/:firm/*" element={
|
||||||
|
<ErrorBoundary fallback={<><Header /><Error404Page /></>} >
|
||||||
|
<FirmContextProvider>
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <FirmHome /> } />
|
||||||
|
<Route path="/entities/*" element={ <EntityRoutes /> } />
|
||||||
|
<Route path="/provisions/*" element={ <ProvisionRoutes /> } />
|
||||||
|
<Route path="/templates/*" element={ <TemplateRoutes /> } />
|
||||||
|
<Route path="/drafts/*" element={ <DraftRoutes /> } />
|
||||||
|
<Route path="/contracts/*" element={ <ContractRoutes /> } />
|
||||||
|
</Routes>
|
||||||
|
</FirmContextProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FirmHome = () => {
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>{t("dashboard.title")}</h1>
|
||||||
|
<ul>
|
||||||
|
<li><Link to="entities">{t("schemas.entity.resource_plural")}</Link></li>
|
||||||
|
<li><Link to="provisions">{t("schemas.provision_template.resource_plural")}</Link></li>
|
||||||
|
<li><Link to="templates">{t("schemas.contract_template.resource_plural")}</Link></li>
|
||||||
|
<li><Link to="drafts">{t("schemas.contract_draft.resource_plural")}</Link></li>
|
||||||
|
<li><Link to="contracts">{t("schemas.contract.resource_plural")}</Link></li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirmInitFormPros = {
|
||||||
|
currentFirm: IFirm
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FirmInitForm = (props: FirmInitFormPros) => {
|
||||||
|
const { currentFirm } = props;
|
||||||
|
const { translate: t } = useTranslation();
|
||||||
|
const resourceBasePath = `firm`
|
||||||
|
|
||||||
|
const { onFinish } = useForm({
|
||||||
|
resource: `${resourceBasePath}/${currentFirm.instance}/${currentFirm.firm}`,
|
||||||
|
action: "create",
|
||||||
|
redirect: "show",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Initialization of {`${currentFirm.instance} / ${currentFirm.firm}`}</h1>
|
||||||
|
<CrudForm
|
||||||
|
schemaName={"CurrentFirmSchema"}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
defaultValue={{corporation: {entity_data: {activity: t("firm.default_activity") }}}}
|
||||||
|
uiSchema={{
|
||||||
|
corporation: {entity_data: {employees: {"ui:style": {"display": "none"}}}},
|
||||||
|
}}
|
||||||
|
onSubmit={(data: any) => onFinish(data)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,27 @@
|
|||||||
|
import { useForm, useInvalidateAuthStore } from "@refinedev/core";
|
||||||
import { CrudForm } from "../../lib/crud/components/crud-form";
|
import { CrudForm } from "../../lib/crud/components/crud-form";
|
||||||
|
import { empty_user } from "../../providers/auth-provider";
|
||||||
|
|
||||||
export const CreateFirm = () => {
|
export const CreateFirm = () => {
|
||||||
|
const invalidateAuthStore = useInvalidateAuthStore()
|
||||||
|
const refreshUser = () => {
|
||||||
|
empty_user();
|
||||||
|
invalidateAuthStore().then();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceBasePath = "hub/users";
|
||||||
|
const { onFinish } = useForm({
|
||||||
|
resource: `${resourceBasePath}/firms`,
|
||||||
|
action: "create",
|
||||||
|
redirect: "list",
|
||||||
|
onMutationSuccess: data => refreshUser()
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CrudForm schemaName={"FirmCreate"} resource={"firms"} />
|
<CrudForm
|
||||||
|
schemaName={"Firm"}
|
||||||
|
resourceBasePath={resourceBasePath}
|
||||||
|
onSubmit={(data: any) => onFinish(data)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,53 @@
|
|||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import { Link } from "react-router";
|
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
||||||
|
import React from 'react';
|
||||||
|
import {Link, Route, Routes} from "react-router";
|
||||||
|
import { useGetIdentity, useList } from "@refinedev/core";
|
||||||
|
import { IAuthUser, IFirm } from "../../interfaces";
|
||||||
|
import {CreateFirm} from "./CreateFirm";
|
||||||
|
import {Header} from "../../components";
|
||||||
|
|
||||||
export const Hub = () => {
|
|
||||||
|
export const HubRoutes = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route index element={ <HubHome /> } />
|
||||||
|
<Route path="create-firm" element={ <CreateFirm /> } />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HubHome = () => {
|
||||||
|
const { data: user } = useGetIdentity<IAuthUser>();
|
||||||
|
const { data: list } = useList<IFirm>({resource: "hub/users/firms", pagination: { mode: "off" }}, )
|
||||||
|
if (user === undefined || user === null || list === undefined) {
|
||||||
|
return <p>Loading</p>;
|
||||||
|
}
|
||||||
|
const ownedFirms = list.data;
|
||||||
|
if (user === undefined || ownedFirms === undefined) {
|
||||||
|
return <p>Loading</p>
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>HUB</h1>
|
<h1>HUB</h1>
|
||||||
<p>List of managed firms</p>
|
<p>List of managed firms</p>
|
||||||
<p>List of firm you're working atx</p>
|
<ul>
|
||||||
<Link to="/hub/create-firm" ><Button >Create a new firm</Button></Link>
|
{ownedFirms.map((f: IFirm, index) => (
|
||||||
|
<li key={index}>{f.instance} / {f.firm}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link to="/hub/create-firm" ><Button>Create a new firm</Button></Link>
|
||||||
|
<p>List of firm you're working at</p>
|
||||||
|
<ul>
|
||||||
|
{user.firms.map((f: IFirm, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{f.instance} / {f.firm} <Link to={`/firm/${f.instance}/${f.firm}`}><ExitToAppIcon /></Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { AuthProvider } from "@refinedev/core";
|
import isEmpty from 'lodash/isEmpty';
|
||||||
|
import { AuthProvider, OnErrorResponse } from "@refinedev/core";
|
||||||
|
import { IUser } from "../interfaces";
|
||||||
|
|
||||||
const API_URL = "/api/v1";
|
const API_URL = "/api/v1";
|
||||||
const LOCAL_STORAGE_USER_KEY = "rpk-gui-current-user";
|
const LOCAL_STORAGE_USER_KEY = "rpk-gui-current-user";
|
||||||
const GOOGLE_SCOPES = { "scopes": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email" };
|
const GOOGLE_SCOPES = { "scopes": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email" };
|
||||||
const DISCORD_SCOPES = { "scopes": "identify email" }
|
const DISCORD_SCOPES = { "scopes": "identify email" }
|
||||||
|
|
||||||
export const authProvider: AuthProvider = {
|
const DEFAULT_LOGIN_REDIRECT = "/hub"
|
||||||
|
|
||||||
|
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 ? getLoginRedirect() : to_param
|
||||||
if (providerName) {
|
if (providerName) {
|
||||||
let scope = {};
|
let scope = {};
|
||||||
if (providerName === "google") {
|
if (providerName === "google") {
|
||||||
@@ -16,19 +21,22 @@ export const authProvider: AuthProvider = {
|
|||||||
scope = DISCORD_SCOPES;
|
scope = DISCORD_SCOPES;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(scope);
|
const params = new URLSearchParams(scope);
|
||||||
const url = `${API_URL}/auth/${providerName}/authorize?${params.toString()}`;
|
const url = `${API_URL}/hub/auth/${providerName}/authorize?${params.toString()}`;
|
||||||
const response = await fetch(url, { method: "GET", },);
|
const response = await fetch(url, { method: "GET", },);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (to_param) {
|
|
||||||
localStorage.setItem("redirect_after_login", to_param);
|
localStorage.setItem("redirect_after_login", redirect);
|
||||||
}
|
|
||||||
window.location.href = body.authorization_url;
|
window.location.href = body.authorization_url;
|
||||||
return { success: true };
|
return {
|
||||||
|
success: true,
|
||||||
|
redirectTo: ""
|
||||||
|
};
|
||||||
} else if (email !== undefined && password !== undefined) {
|
} else if (email !== undefined && password !== undefined) {
|
||||||
const params = new URLSearchParams({"grant_type": "password", "username": email, "password": password});
|
const params = new URLSearchParams({"grant_type": "password", "username": email, "password": password});
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_URL}/auth/login`,
|
`${API_URL}/hub/auth/login`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: params.toString(),
|
body: params.toString(),
|
||||||
@@ -36,18 +44,21 @@ export const authProvider: AuthProvider = {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (response.status >= 200 && response.status < 300) {
|
if (response.status >= 200 && response.status < 300) {
|
||||||
const response = await fetch(`${API_URL}/users/me`);
|
const response = await fetch(`${API_URL}/hub/users/me`);
|
||||||
const user = await response.json();
|
const user = await response.json();
|
||||||
store_user(user);
|
store_user(user);
|
||||||
|
|
||||||
return { success: true };
|
return {
|
||||||
|
success: true,
|
||||||
|
redirectTo: redirect,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
const response = await fetch(`${API_URL}/auth/logout`, { method: "POST" });
|
const response = await fetch(`${API_URL}/hub/auth/logout`, { method: "POST" });
|
||||||
if (response.status == 204 || response.status == 401) {
|
if (response.status == 204 || response.status == 401) {
|
||||||
forget_user();
|
forget_user();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -55,25 +66,35 @@ export const authProvider: AuthProvider = {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
},
|
},
|
||||||
check: async () => {
|
check: async () => {
|
||||||
return { authenticated: Boolean(get_user()) };
|
|
||||||
},
|
|
||||||
getIdentity: async () => {
|
|
||||||
const user = get_user();
|
const user = get_user();
|
||||||
if (user != null) {
|
if (user == null || isEmpty(user)) {
|
||||||
|
const user_data = await get_me();
|
||||||
|
|
||||||
|
if (user_data) {
|
||||||
|
store_user(user_data)
|
||||||
|
return { authenticated: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
logout: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { authenticated: true };
|
||||||
|
},
|
||||||
|
getIdentity: async (): Promise<IUser> => {
|
||||||
|
const user = get_user();
|
||||||
|
if (user !== null && !isEmpty(user)) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/users/me`);
|
const user_data = get_me()
|
||||||
if (response.status < 200 || response.status > 299) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const user_data = await response.json();
|
|
||||||
store_user(user_data)
|
store_user(user_data)
|
||||||
|
|
||||||
return user_data;
|
return user_data;
|
||||||
},
|
},
|
||||||
register: async (params) => {
|
register: async (params) => {
|
||||||
const response = await fetch(`${API_URL}/register`, {
|
const response = await fetch(`${API_URL}/hub/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
headers: {
|
headers: {
|
||||||
@@ -94,7 +115,7 @@ export const authProvider: AuthProvider = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
forgotPassword: async (params) => {
|
forgotPassword: async (params) => {
|
||||||
const response = await fetch(`${API_URL}/users/forgot-password`, {
|
const response = await fetch(`${API_URL}/hub/users/forgot-password`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
headers: {
|
headers: {
|
||||||
@@ -110,7 +131,7 @@ export const authProvider: AuthProvider = {
|
|||||||
},
|
},
|
||||||
updatePassword: async (params) => {
|
updatePassword: async (params) => {
|
||||||
if (params.token !== undefined) {
|
if (params.token !== undefined) {
|
||||||
const response = await fetch(`${API_URL}/users/reset-password`, {
|
const response = await fetch(`${API_URL}/hub/users/reset-password`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
password: params.password,
|
password: params.password,
|
||||||
@@ -131,22 +152,32 @@ export const authProvider: AuthProvider = {
|
|||||||
getPermissions: async () => { throw new Error("Not implemented"); },
|
getPermissions: async () => { throw new Error("Not implemented"); },
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
if (error?.status === 401) {
|
if (error?.status === 401) {
|
||||||
forget_user()
|
forget_user();
|
||||||
return Promise<{
|
return {
|
||||||
redirectTo: "/login",
|
|
||||||
logout: true,
|
|
||||||
error: { message: "Authentication required" },
|
error: { message: "Authentication required" },
|
||||||
}>;
|
logout: true,
|
||||||
|
} as OnErrorResponse;
|
||||||
}
|
}
|
||||||
else if (error?.status === 403) {
|
else if (error?.status === 403) {
|
||||||
return Promise<{
|
return {
|
||||||
error: { message: "Insufficient credentials" },
|
error: { message: "Insufficient credentials" },
|
||||||
}>;
|
} as OnErrorResponse;
|
||||||
}
|
}
|
||||||
return {};
|
return {
|
||||||
|
error: { message: "Unexpected authentication error" },
|
||||||
|
} as OnErrorResponse;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function get_me() {
|
||||||
|
const response = await fetch(`${API_URL}/hub/users/me`);
|
||||||
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const user_data = await response.json();
|
||||||
|
return user_data
|
||||||
|
}
|
||||||
|
|
||||||
function store_user(user: any) {
|
function store_user(user: any) {
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(user));
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(user));
|
||||||
}
|
}
|
||||||
@@ -163,12 +194,25 @@ function forget_user() {
|
|||||||
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function empty_user() {
|
||||||
|
store_user({})
|
||||||
|
}
|
||||||
|
|
||||||
function findGetParameter(parameterName: string) {
|
function findGetParameter(parameterName: string) {
|
||||||
let result = null, tmp = [];
|
let result = null, tmp = [];
|
||||||
location.search.substr(1).split("&")
|
location.search.substring(1).split("&")
|
||||||
.forEach(function (item) {
|
.forEach(function (item) {
|
||||||
tmp = item.split("=");
|
tmp = item.split("=");
|
||||||
if (tmp[0] === parameterName) result = decodeURIComponent(tmp[1]);
|
if (tmp[0] === parameterName) result = decodeURIComponent(tmp[1]);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLoginRedirect() {
|
||||||
|
if (location.pathname == "/login") {
|
||||||
|
return DEFAULT_LOGIN_REDIRECT
|
||||||
|
}
|
||||||
|
|
||||||
|
return location.pathname + location.search;
|
||||||
|
}
|
||||||
|
export default authProvider;
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import type { DataProvider } from "@refinedev/core";
|
import type { DataProvider, HttpError } from "@refinedev/core";
|
||||||
|
|
||||||
const API_URL = "http://localhost:8000";
|
const API_URL = "/api/v1";
|
||||||
|
|
||||||
const fetcher = async (url: string, options?: RequestInit) => {
|
function handleErrors(response: { status: number, statusText: string }) {
|
||||||
return fetch(url, {
|
let message = response.statusText
|
||||||
...options,
|
if (response.status == 405) {
|
||||||
headers: {
|
message = "Resource is not ready";
|
||||||
...options?.headers,
|
}
|
||||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
const error: HttpError = {
|
||||||
},
|
message: message,
|
||||||
});
|
statusCode: response.status,
|
||||||
};
|
};
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
export const dataProvider: DataProvider = {
|
const dataProvider: DataProvider = {
|
||||||
getOne: async ({ resource, id, meta }) => {
|
getOne: async ({ resource, id, meta }) => {
|
||||||
const response = id !== "" ? await fetcher(`${API_URL}/${resource}/${id}`) : await fetcher(`${API_URL}/${resource}`);
|
if (id === "") {
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
return { data: undefined };
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_URL}/${resource}/${id}`);
|
||||||
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return handleErrors(response);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -24,7 +31,7 @@ export const dataProvider: DataProvider = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
update: async ({ resource, id, variables }) => {
|
update: async ({ resource, id, variables }) => {
|
||||||
const response = await fetcher(`${API_URL}/${resource}/${id}`, {
|
const response = await fetch(`${API_URL}/${resource}/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(variables),
|
body: JSON.stringify(variables),
|
||||||
headers: {
|
headers: {
|
||||||
@@ -32,16 +39,18 @@ export const dataProvider: DataProvider = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return handleErrors(response);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return { data };
|
return { data };
|
||||||
},
|
},
|
||||||
getList: async ({ resource, pagination, filters, sorters, meta }) => {
|
getList: async ({ resource, pagination, filters, sorters, meta }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (pagination) {
|
const serverPagination = pagination && pagination.mode =="server";
|
||||||
|
if (serverPagination) {
|
||||||
params.append("page", String(pagination.current));
|
params.append("page", String(pagination.current));
|
||||||
params.append("size", String(pagination.pageSize));
|
params.append("size", String(pagination.pageSize));
|
||||||
}
|
}
|
||||||
@@ -52,25 +61,37 @@ export const dataProvider: DataProvider = {
|
|||||||
|
|
||||||
if (filters && filters.length > 0) {
|
if (filters && filters.length > 0) {
|
||||||
filters.forEach((filter) => {
|
filters.forEach((filter) => {
|
||||||
if ("field" in filter && filter.value && filter.operator === "contains") {
|
if ("field" in filter) {
|
||||||
params.append(filter.field + "__like", "%" + filter.value + "%");
|
if (filter.field == "search") {
|
||||||
|
params.append("search", filter.value);
|
||||||
|
} else {
|
||||||
|
params.append(`${filter.field.replace(".", "__")}__${filter.operator}`, filter.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetcher(`${API_URL}/${resource}?${params.toString()}`);
|
const response = await fetch(`${API_URL}/${resource}/?${params.toString()}`);
|
||||||
|
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return handleErrors(response);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (serverPagination) {
|
||||||
return {
|
return {
|
||||||
data: data.items,
|
data: data.items,
|
||||||
total: data.total, // We'll cover this in the next steps.
|
total: data.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: data,
|
||||||
|
total: data.length,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
create: async ({ resource, variables }) => {
|
create: async ({ resource, variables }) => {
|
||||||
const response = await fetcher(`${API_URL}/${resource}`, {
|
const response = await fetch(`${API_URL}/${resource}/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(variables),
|
body: JSON.stringify(variables),
|
||||||
headers: {
|
headers: {
|
||||||
@@ -78,24 +99,24 @@ export const dataProvider: DataProvider = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return handleErrors(response);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return { data };
|
return { data };
|
||||||
},
|
},
|
||||||
deleteOne: async ({ resource, id, variables, meta }) => {
|
deleteOne: async ({ resource, id, variables, meta }) => {
|
||||||
const response = await fetcher(`${API_URL}/${resource}/${id}`,{
|
const response = await fetch(`${API_URL}/${resource}/${id}`,{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
return handleErrors(response);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
return { data };
|
||||||
return {
|
|
||||||
data
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
getApiUrl: () => API_URL,
|
getApiUrl: () => API_URL,
|
||||||
// Optional methods:
|
// Optional methods:
|
||||||
@@ -105,3 +126,5 @@ export const dataProvider: DataProvider = {
|
|||||||
// updateMany: () => { /* ... */ },
|
// updateMany: () => { /* ... */ },
|
||||||
// custom: () => { /* ... */ },
|
// custom: () => { /* ... */ },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default dataProvider;
|
||||||
|
|||||||
5
gui/rpk-gui/src/theme.tsx
Normal file
5
gui/rpk-gui/src/theme.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
const rpcTheme = createTheme({});
|
||||||
|
|
||||||
|
export default rpcTheme
|
||||||
11
i18n/Dockerfile
Normal file
11
i18n/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:lts-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY app/tsconfig*.json ./
|
||||||
|
COPY app/src ./src
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
CMD [ "npm", "--watch", "start" ]
|
||||||
26
i18n/app/.gitignore
vendored
Normal file
26
i18n/app/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
\*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
|
||||||
|
.vscode/_
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
_.suo
|
||||||
|
_.ntvs_
|
||||||
|
_.njsproj
|
||||||
|
_.sln
|
||||||
|
\*.sw?
|
||||||
1164
i18n/app/package-lock.json
generated
Normal file
1164
i18n/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
i18n/app/package.json
Normal file
25
i18n/app/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "i18n Helper",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "ts-node src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"serve": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/node": "^22.15.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
i18n/app/public/locales/.placeholder
Normal file
0
i18n/app/public/locales/.placeholder
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user