Add an accommodations reservations module #188
							
								
								
									
										182
									
								
								geneit_app/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										182
									
								
								geneit_app/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -12,12 +12,18 @@ | ||||
|         "@emotion/react": "^11.11.4", | ||||
|         "@emotion/styled": "^11.11.0", | ||||
|         "@fontsource/roboto": "^5.0.13", | ||||
|         "@fullcalendar/core": "^6.1.14", | ||||
|         "@fullcalendar/daygrid": "^6.1.14", | ||||
|         "@fullcalendar/interaction": "^6.1.14", | ||||
|         "@fullcalendar/list": "^6.1.14", | ||||
|         "@fullcalendar/react": "^6.1.14", | ||||
|         "@mdi/js": "^7.2.96", | ||||
|         "@mdi/react": "^1.6.1", | ||||
|         "@mui/icons-material": "^5.15.17", | ||||
|         "@mui/lab": "^5.0.0-alpha.140", | ||||
|         "@mui/material": "^5.15.17", | ||||
|         "@mui/x-data-grid": "^7.1.1", | ||||
|         "@mui/x-date-pickers": "^7.7.0", | ||||
|         "@mui/x-tree-view": "^7.4.0", | ||||
|         "@testing-library/jest-dom": "^6.4.5", | ||||
|         "@testing-library/react": "^16.0.0", | ||||
| @@ -27,12 +33,15 @@ | ||||
|         "@types/react-dom": "^18.3.0", | ||||
|         "@vitejs/plugin-react": "^4.2.1", | ||||
|         "date-and-time": "^3.2.0", | ||||
|         "dayjs": "^1.11.11", | ||||
|         "email-validator": "^2.0.4", | ||||
|         "filesize": "^10.1.2", | ||||
|         "jspdf": "^2.5.1", | ||||
|         "mui-color-input": "^2.0.3", | ||||
|         "react": "^18.3.1", | ||||
|         "react-dom": "^18.3.1", | ||||
|         "react-easy-crop": "^5.0.7", | ||||
|         "react-qr-code": "^2.0.14", | ||||
|         "react-router-dom": "^6.23.1", | ||||
|         "react-zoom-pan-pinch": "^3.4.4", | ||||
|         "svg2pdf.js": "^2.2.3", | ||||
| @@ -511,6 +520,14 @@ | ||||
|         "node": ">=6.9.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@ctrl/tinycolor": { | ||||
|       "version": "4.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz", | ||||
|       "integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==", | ||||
|       "engines": { | ||||
|         "node": ">=14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@emotion/babel-plugin": { | ||||
|       "version": "11.11.0", | ||||
|       "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", | ||||
| @@ -1056,6 +1073,48 @@ | ||||
|       "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.13.tgz", | ||||
|       "integrity": "sha512-j61DHjsdUCKMXSdNLTOxcG701FWnF0jcqNNQi2iPCDxU8seN/sMxeh62dC++UiagCWq9ghTypX+Pcy7kX+QOeQ==" | ||||
|     }, | ||||
|     "node_modules/@fullcalendar/core": { | ||||
|       "version": "6.1.14", | ||||
|       "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.14.tgz", | ||||
|       "integrity": "sha512-hIPRBevm0aMc2aHy1hRIJgXmI1QTvQM1neQa9oxtuqUmF1+ApYC3oAdwcQMTuI7lHHw3pKJDyJFkKLPPnL6HXA==", | ||||
|       "dependencies": { | ||||
|         "preact": "~10.12.1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@fullcalendar/daygrid": { | ||||
|       "version": "6.1.14", | ||||
|       "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.14.tgz", | ||||
|       "integrity": "sha512-DSyjiA1dEM8k3bOCrZpZOmAOZu71KGtH02ze+4QKuhxkmn/zQghmmLRdfzpOrcyJg6xGKkoB4pBcO+2lXar8XQ==", | ||||
|       "peerDependencies": { | ||||
|         "@fullcalendar/core": "~6.1.14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@fullcalendar/interaction": { | ||||
|       "version": "6.1.14", | ||||
|       "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.14.tgz", | ||||
|       "integrity": "sha512-rXum5XCjq+WEPNctFeYL/JKZGeU2rlxrElygocdMegcrIBJQW5hnWWVE+i4/1dOmUKF80CbGVlXUyYXoqK2eFg==", | ||||
|       "peerDependencies": { | ||||
|         "@fullcalendar/core": "~6.1.14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@fullcalendar/list": { | ||||
|       "version": "6.1.14", | ||||
|       "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.14.tgz", | ||||
|       "integrity": "sha512-eV0/6iCumYfvlPzIUTAONWH17/JlQCyCChUz8m06L4E/sOiNjkHGz8vlVTmZKqXzx9oWOOyV/Nm3pCtHmVZh+Q==", | ||||
|       "peerDependencies": { | ||||
|         "@fullcalendar/core": "~6.1.14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@fullcalendar/react": { | ||||
|       "version": "6.1.14", | ||||
|       "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.14.tgz", | ||||
|       "integrity": "sha512-sXLn2D8aPYLuDH3fy2ZhHTOz5WNSU1NhoECsGBzjUtz2IYHy6m5Y9TqlyqeAqVqFLDRSJAlKAr5LyrIvnD/IMA==", | ||||
|       "peerDependencies": { | ||||
|         "@fullcalendar/core": "~6.1.14", | ||||
|         "react": "^16.7.0 || ^17 || ^18 || ^19", | ||||
|         "react-dom": "^16.7.0 || ^17 || ^18 || ^19" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jest/expect-utils": { | ||||
|       "version": "29.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", | ||||
| @@ -1531,6 +1590,71 @@ | ||||
|         "react-dom": "^17.0.0 || ^18.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-date-pickers": { | ||||
|       "version": "7.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.7.0.tgz", | ||||
|       "integrity": "sha512-huyoA22Vi8iCkee6ro0sX7CcFIcPV/Fl7ZGWwaQC8PTAheXhz823DjMYAiwRU/imF+UFYfUInWQ4XZCIkM+2Dw==", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.24.7", | ||||
|         "@mui/base": "^5.0.0-beta.40", | ||||
|         "@mui/system": "^5.15.15", | ||||
|         "@mui/utils": "^5.15.14", | ||||
|         "@types/react-transition-group": "^4.4.10", | ||||
|         "clsx": "^2.1.1", | ||||
|         "prop-types": "^15.8.1", | ||||
|         "react-transition-group": "^4.4.5" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@emotion/react": "^11.9.0", | ||||
|         "@emotion/styled": "^11.8.1", | ||||
|         "@mui/material": "^5.15.14", | ||||
|         "date-fns": "^2.25.0 || ^3.2.0", | ||||
|         "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", | ||||
|         "dayjs": "^1.10.7", | ||||
|         "luxon": "^3.0.2", | ||||
|         "moment": "^2.29.4", | ||||
|         "moment-hijri": "^2.1.2", | ||||
|         "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", | ||||
|         "react": "^17.0.0 || ^18.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@emotion/react": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "@emotion/styled": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "date-fns": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "date-fns-jalali": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "dayjs": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "luxon": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment-hijri": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment-jalaali": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-tree-view": { | ||||
|       "version": "7.7.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.7.1.tgz", | ||||
| @@ -2504,6 +2628,11 @@ | ||||
|       "integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/dayjs": { | ||||
|       "version": "1.11.11", | ||||
|       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", | ||||
|       "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" | ||||
|     }, | ||||
|     "node_modules/debug": { | ||||
|       "version": "4.3.4", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
| @@ -3335,6 +3464,27 @@ | ||||
|       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||
|       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" | ||||
|     }, | ||||
|     "node_modules/mui-color-input": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-2.0.3.tgz", | ||||
|       "integrity": "sha512-rAd040qQ0Y+8dk4gE8kkCiJ/vCgA0j4vv1quJ43BfORTFE3uHarHj0xY1Vo9CPbojtx1f5vW+CjckYPRIZPIRg==", | ||||
|       "dependencies": { | ||||
|         "@ctrl/tinycolor": "^4.0.3" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@emotion/react": "^11.5.0", | ||||
|         "@emotion/styled": "^11.3.0", | ||||
|         "@mui/material": "^5.0.0", | ||||
|         "@types/react": "^18.0.0", | ||||
|         "react": "^18.0.0", | ||||
|         "react-dom": "^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@types/react": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/nanoid": { | ||||
|       "version": "3.3.7", | ||||
|       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", | ||||
| @@ -3460,6 +3610,15 @@ | ||||
|         "node": "^10 || ^12 || >=14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/preact": { | ||||
|       "version": "10.12.1", | ||||
|       "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", | ||||
|       "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/preact" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pretty-format": { | ||||
|       "version": "27.5.1", | ||||
|       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", | ||||
| @@ -3507,6 +3666,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | ||||
|       "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" | ||||
|     }, | ||||
|     "node_modules/qr.js": { | ||||
|       "version": "0.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", | ||||
|       "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" | ||||
|     }, | ||||
|     "node_modules/raf": { | ||||
|       "version": "3.4.1", | ||||
|       "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", | ||||
| @@ -3557,6 +3721,24 @@ | ||||
|       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", | ||||
|       "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" | ||||
|     }, | ||||
|     "node_modules/react-qr-code": { | ||||
|       "version": "2.0.14", | ||||
|       "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.14.tgz", | ||||
|       "integrity": "sha512-xvAUqmXzFzf7X6aQAAKb6T02YYk9grBBFeqpp1MiVhUAKG3Rg9+hFiOKRYg4+rWc2MiXNxkri0ulAJgS12xh7Q==", | ||||
|       "dependencies": { | ||||
|         "prop-types": "^15.8.1", | ||||
|         "qr.js": "0.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": "*", | ||||
|         "react-native-svg": "*" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "react-native-svg": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-refresh": { | ||||
|       "version": "0.14.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", | ||||
|   | ||||
| @@ -8,12 +8,18 @@ | ||||
|     "@emotion/react": "^11.11.4", | ||||
|     "@emotion/styled": "^11.11.0", | ||||
|     "@fontsource/roboto": "^5.0.13", | ||||
|     "@fullcalendar/core": "^6.1.14", | ||||
|     "@fullcalendar/daygrid": "^6.1.14", | ||||
|     "@fullcalendar/interaction": "^6.1.14", | ||||
|     "@fullcalendar/list": "^6.1.14", | ||||
|     "@fullcalendar/react": "^6.1.14", | ||||
|     "@mdi/js": "^7.2.96", | ||||
|     "@mdi/react": "^1.6.1", | ||||
|     "@mui/icons-material": "^5.15.17", | ||||
|     "@mui/lab": "^5.0.0-alpha.140", | ||||
|     "@mui/material": "^5.15.17", | ||||
|     "@mui/x-data-grid": "^7.1.1", | ||||
|     "@mui/x-date-pickers": "^7.7.0", | ||||
|     "@mui/x-tree-view": "^7.4.0", | ||||
|     "@testing-library/jest-dom": "^6.4.5", | ||||
|     "@testing-library/react": "^16.0.0", | ||||
| @@ -23,12 +29,15 @@ | ||||
|     "@types/react-dom": "^18.3.0", | ||||
|     "@vitejs/plugin-react": "^4.2.1", | ||||
|     "date-and-time": "^3.2.0", | ||||
|     "dayjs": "^1.11.11", | ||||
|     "email-validator": "^2.0.4", | ||||
|     "filesize": "^10.1.2", | ||||
|     "jspdf": "^2.5.1", | ||||
|     "mui-color-input": "^2.0.3", | ||||
|     "react": "^18.3.1", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "react-easy-crop": "^5.0.7", | ||||
|     "react-qr-code": "^2.0.14", | ||||
|     "react-router-dom": "^6.23.1", | ||||
|     "react-zoom-pan-pinch": "^3.4.4", | ||||
|     "svg2pdf.js": "^2.2.3", | ||||
|   | ||||
| @@ -16,29 +16,33 @@ import { NewAccountRoute } from "./routes/auth/NewAccountRoute"; | ||||
| import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; | ||||
| import { PasswordForgottenRoute } from "./routes/auth/PasswordForgottenRoute"; | ||||
| import { ResetPasswordRoute } from "./routes/auth/ResetPasswordRoute"; | ||||
| import { FamilyHomeRoute } from "./routes/family/genealogy/FamilyHomeRoute"; | ||||
| import { | ||||
|   FamilyCreateMemberRoute, | ||||
|   FamilyEditMemberRoute, | ||||
|   FamilyMemberRoute, | ||||
| } from "./routes/family/genealogy/FamilyMemberRoute"; | ||||
| import { FamilySettingsRoute } from "./routes/family/FamilySettingsRoute"; | ||||
| import { FamilyUsersListRoute } from "./routes/family/FamilyUsersListRoute"; | ||||
| import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; | ||||
| import { BaseFamilyRoute } from "./widgets/BaseFamilyRoute"; | ||||
| import { BaseLoginPage } from "./widgets/BaseLoginpage"; | ||||
| import { FamilyMembersListRoute } from "./routes/family/genealogy/FamilyMembersListRoute"; | ||||
| import { AccommodationsHomeRoute } from "./routes/family/accommodations/AccommodationsHomeRoute"; | ||||
| import { AccommodationsReservationsRoute } from "./routes/family/accommodations/AccommodationsReservationsRoute"; | ||||
| import { AccommodationsSettingsRoute } from "./routes/family/accommodations/AccommodationsSettingsRoute"; | ||||
| import { | ||||
|   FamilyCoupleRoute, | ||||
|   FamilyCreateCoupleRoute, | ||||
|   FamilyEditCoupleRoute, | ||||
| } from "./routes/family/genealogy/FamilyCoupleRoute"; | ||||
| import { FamilyCouplesListRoute } from "./routes/family/genealogy/FamilyCouplesListRoute"; | ||||
| import { FamilyTreeRoute } from "./routes/family/genealogy/FamilyTreeRoute"; | ||||
| import { FamilyHomeRoute } from "./routes/family/genealogy/FamilyHomeRoute"; | ||||
| import { | ||||
|   FamilyCreateMemberRoute, | ||||
|   FamilyEditMemberRoute, | ||||
|   FamilyMemberRoute, | ||||
| } from "./routes/family/genealogy/FamilyMemberRoute"; | ||||
| import { FamilyMemberTreeRoute } from "./routes/family/genealogy/FamilyMemberTreeRoute"; | ||||
| import { GenealogyHomeRoute } from "./routes/family/genealogy/GenealogyHomeRoute"; | ||||
| import { BaseGenealogyRoute } from "./widgets/genealogy/BaseGenealogyRoute"; | ||||
| import { FamilyMembersListRoute } from "./routes/family/genealogy/FamilyMembersListRoute"; | ||||
| import { FamilyTreeRoute } from "./routes/family/genealogy/FamilyTreeRoute"; | ||||
| import { GenalogySettingsRoute } from "./routes/family/genealogy/GenalogySettingsRoute"; | ||||
| import { GenealogyHomeRoute } from "./routes/family/genealogy/GenealogyHomeRoute"; | ||||
| import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; | ||||
| import { BaseFamilyRoute } from "./widgets/BaseFamilyRoute"; | ||||
| import { BaseLoginPage } from "./widgets/BaseLoginpage"; | ||||
| import { BaseAccommodationsRoute } from "./widgets/accommodations/BaseAccommodationsRoute"; | ||||
| import { BaseGenealogyRoute } from "./widgets/genealogy/BaseGenealogyRoute"; | ||||
|  | ||||
| interface AuthContext { | ||||
|   signedIn: boolean; | ||||
| @@ -110,6 +114,22 @@ export function App(): React.ReactElement { | ||||
|                 <Route path="*" element={<NotFoundRoute />} /> | ||||
|               </Route> | ||||
|  | ||||
|               <Route | ||||
|                 path="accommodations/*" | ||||
|                 element={<BaseAccommodationsRoute />} | ||||
|               > | ||||
|                 <Route path="" element={<AccommodationsHomeRoute />} /> | ||||
|                 <Route | ||||
|                   path="reservations" | ||||
|                   element={<AccommodationsReservationsRoute />} | ||||
|                 /> | ||||
|                 <Route | ||||
|                   path="settings" | ||||
|                   element={<AccommodationsSettingsRoute />} | ||||
|                 /> | ||||
|                 <Route path="*" element={<NotFoundRoute />} /> | ||||
|               </Route> | ||||
|  | ||||
|               <Route path="settings" element={<FamilySettingsRoute />} /> | ||||
|               <Route path="users" element={<FamilyUsersListRoute />} /> | ||||
|               <Route path="*" element={<NotFoundRoute />} /> | ||||
|   | ||||
| @@ -88,10 +88,12 @@ export class Family implements FamilyAPI { | ||||
| export class ExtendedFamilyInfo extends Family { | ||||
|   public disable_couple_photos: boolean; | ||||
|   public enable_genealogy: boolean; | ||||
|   public enable_accommodations: boolean; | ||||
|   constructor(p: any) { | ||||
|     super(p); | ||||
|     this.disable_couple_photos = p.disable_couple_photos; | ||||
|     this.enable_genealogy = p.enable_genealogy; | ||||
|     this.enable_accommodations = p.enable_accommodations; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -235,6 +237,7 @@ export class FamilyApi { | ||||
|     id: number; | ||||
|     name?: string; | ||||
|     enable_genealogy?: boolean; | ||||
|     enable_accommodations?: boolean; | ||||
|     disable_couple_photos?: boolean; | ||||
|   }): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
| @@ -243,6 +246,7 @@ export class FamilyApi { | ||||
|       jsonData: { | ||||
|         name: settings.name, | ||||
|         enable_genealogy: settings.enable_genealogy, | ||||
|         enable_accommodations: settings.enable_accommodations, | ||||
|         disable_couple_photos: settings.disable_couple_photos, | ||||
|       }, | ||||
|     }); | ||||
|   | ||||
| @@ -32,6 +32,9 @@ interface Constraints { | ||||
|   member_country: LenConstraint; | ||||
|   member_sex: LenConstraint; | ||||
|   member_note: LenConstraint; | ||||
|   accommodation_name_len: LenConstraint; | ||||
|   accommodation_description_len: LenConstraint; | ||||
|   accommodation_calendar_name_len: LenConstraint; | ||||
| } | ||||
|  | ||||
| interface OIDCProvider { | ||||
|   | ||||
							
								
								
									
										124
									
								
								geneit_app/src/api/accommodations/AccommodationListApi.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								geneit_app/src/api/accommodations/AccommodationListApi.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| import { APIClient } from "../ApiClient"; | ||||
| import { Family } from "../FamilyApi"; | ||||
|  | ||||
| export interface Accommodation { | ||||
|   id: number; | ||||
|   family_id: number; | ||||
|   time_create: number; | ||||
|   time_update: number; | ||||
|   name: string; | ||||
|   need_validation: boolean; | ||||
|   description?: string; | ||||
|   color?: string; | ||||
|   open_to_reservations: boolean; | ||||
| } | ||||
|  | ||||
| export class AccommodationsList { | ||||
|   private list: Accommodation[]; | ||||
|   private map: Map<number, Accommodation>; | ||||
|  | ||||
|   constructor(list: Accommodation[]) { | ||||
|     this.list = list; | ||||
|     this.map = new Map(); | ||||
|  | ||||
|     for (const m of list) { | ||||
|       this.map.set(m.id, m); | ||||
|     } | ||||
|  | ||||
|     this.list.sort((a, b) => | ||||
|       a.name.toLowerCase().localeCompare(b.name.toLocaleLowerCase()) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public get isEmpty(): boolean { | ||||
|     return this.list.length === 0; | ||||
|   } | ||||
|  | ||||
|   public get size(): number { | ||||
|     return this.list.length; | ||||
|   } | ||||
|  | ||||
|   public get fullList(): Accommodation[] { | ||||
|     return this.list; | ||||
|   } | ||||
|  | ||||
|   filter(predicate: (m: Accommodation) => boolean): Accommodation[] { | ||||
|     return this.list.filter(predicate); | ||||
|   } | ||||
|  | ||||
|   get openToReservationList(): Accommodation[] { | ||||
|     return this.filter((a) => a.open_to_reservations); | ||||
|   } | ||||
|  | ||||
|   get(id: number): Accommodation | undefined { | ||||
|     return this.map.get(id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface UpdateAccommodation { | ||||
|   name: string; | ||||
|   need_validation: boolean; | ||||
|   description?: string; | ||||
|   color?: string; | ||||
|   open_to_reservations: boolean; | ||||
| } | ||||
|  | ||||
| export class AccommodationListApi { | ||||
|   /** | ||||
|    * Get the list of accommodation of a family | ||||
|    */ | ||||
|   static async GetListOfFamily(family: Family): Promise<AccommodationsList> { | ||||
|     const data = ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/family/${family.family_id}/accommodations/list/list`, | ||||
|       }) | ||||
|     ).data; | ||||
|  | ||||
|     return new AccommodationsList(data); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create a new accommodation | ||||
|    */ | ||||
|   static async Create( | ||||
|     family: Family, | ||||
|     accommodation: UpdateAccommodation | ||||
|   ): Promise<Accommodation> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "POST", | ||||
|         uri: `/family/${family.family_id}/accommodations/list/create`, | ||||
|         jsonData: accommodation, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update an accommodation | ||||
|    */ | ||||
|   static async Update( | ||||
|     accommodation: Accommodation, | ||||
|     update: UpdateAccommodation | ||||
|   ): Promise<Accommodation> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "PUT", | ||||
|         uri: `/family/${accommodation.family_id}/accommodations/list/${accommodation.id}`, | ||||
|         jsonData: update, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete an accommodation | ||||
|    */ | ||||
|   static async Delete(accommodation: Accommodation): Promise<Accommodation> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "DELETE", | ||||
|         uri: `/family/${accommodation.family_id}/accommodations/list/${accommodation.id}`, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| import { APIClient } from "../ApiClient"; | ||||
| import { Family } from "../FamilyApi"; | ||||
|  | ||||
| export interface NewCalendarURL { | ||||
|   accommodation_id?: number; | ||||
|   name: string; | ||||
| } | ||||
|  | ||||
| export interface AccommodationCalendarURL { | ||||
|   id: number; | ||||
|   family_id: number; | ||||
|   accommodation_id: number; | ||||
|   user_id: number; | ||||
|   name: string; | ||||
|   token: string; | ||||
|   time_create: number; | ||||
|   time_used: number; | ||||
| } | ||||
|  | ||||
| export class AccommodationsCalendarURLApi { | ||||
|   /** | ||||
|    * Create a new accommodation calendar URL | ||||
|    */ | ||||
|   static async Create( | ||||
|     family: Family, | ||||
|     calendar: NewCalendarURL | ||||
|   ): Promise<AccommodationCalendarURL> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "POST", | ||||
|         uri: `/family/${family.family_id}/accommodations/reservations_calendars/create`, | ||||
|         jsonData: calendar, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get accommodation calendar URL route | ||||
|    */ | ||||
|   static CalendarURL(c: AccommodationCalendarURL): string { | ||||
|     return `${APIClient.backendURL()}/acccommodations_calendar/${c.token}`; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get accommodations calendars list | ||||
|    */ | ||||
|   static async GetList(family: Family): Promise<AccommodationCalendarURL[]> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/family/${family.family_id}/accommodations/reservations_calendars/list`, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete an accommodation calendar | ||||
|    */ | ||||
|   static async Delete( | ||||
|     calendar: AccommodationCalendarURL | ||||
|   ): Promise<AccommodationCalendarURL> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "DELETE", | ||||
|         uri: `/family/${calendar.family_id}/accommodations/reservations_calendars/${calendar.id}`, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,175 @@ | ||||
| import { APIClient } from "../ApiClient"; | ||||
| import { Family } from "../FamilyApi"; | ||||
| import { Accommodation } from "./AccommodationListApi"; | ||||
|  | ||||
| export enum ValidateResaResult { | ||||
|   Success, | ||||
|   Error, | ||||
|   Conflict, | ||||
| } | ||||
|  | ||||
| export interface AccommodationReservation { | ||||
|   id: number; | ||||
|   family_id: number; | ||||
|   accommodation_id: number; | ||||
|   user_id: number; | ||||
|   time_create: number; | ||||
|   time_update: number; | ||||
|   reservation_start: number; | ||||
|   reservation_end: number; | ||||
|   validated?: boolean; | ||||
| } | ||||
|  | ||||
| export class AccommodationsReservationsList { | ||||
|   private list: AccommodationReservation[]; | ||||
|   private map: Map<number, AccommodationReservation>; | ||||
|  | ||||
|   constructor(list: AccommodationReservation[]) { | ||||
|     this.list = list; | ||||
|     this.map = new Map(); | ||||
|  | ||||
|     for (const m of list) { | ||||
|       this.map.set(m.id, m); | ||||
|     } | ||||
|  | ||||
|     this.list.sort((a, b) => a.reservation_start - b.reservation_start); | ||||
|   } | ||||
|  | ||||
|   public get isEmpty(): boolean { | ||||
|     return this.list.length === 0; | ||||
|   } | ||||
|  | ||||
|   public get size(): number { | ||||
|     return this.list.length; | ||||
|   } | ||||
|  | ||||
|   public get fullList(): AccommodationReservation[] { | ||||
|     return this.list; | ||||
|   } | ||||
|  | ||||
|   filter( | ||||
|     predicate: (m: AccommodationReservation) => boolean | ||||
|   ): AccommodationReservation[] { | ||||
|     return this.list.filter(predicate); | ||||
|   } | ||||
|  | ||||
|   forAccommodation(id: number): AccommodationReservation[] { | ||||
|     return this.filter((a) => a.accommodation_id === id); | ||||
|   } | ||||
|  | ||||
|   get(id: number): AccommodationReservation | undefined { | ||||
|     return this.map.get(id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface UpdateAccommodationReservation { | ||||
|   start: number; | ||||
|   end: number; | ||||
|   accommodation_id: number; | ||||
|   reservation_id?: number; | ||||
| } | ||||
|  | ||||
| export class AccommodationsReservationsApi { | ||||
|   /** | ||||
|    * Create a new reservation | ||||
|    */ | ||||
|   static async Create( | ||||
|     family: Family, | ||||
|     reservation: UpdateAccommodationReservation | ||||
|   ): Promise<AccommodationReservation> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "POST", | ||||
|         uri: `/family/${family.family_id}/accommodations/reservations/accommodation/${reservation.accommodation_id}/create`, | ||||
|         jsonData: { | ||||
|           start: reservation.start, | ||||
|           end: reservation.end, | ||||
|         }, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the entire list of accommodations of a family | ||||
|    */ | ||||
|   static async FullListOfFamily( | ||||
|     family: Family | ||||
|   ): Promise<AccommodationsReservationsList> { | ||||
|     const data = ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/family/${family.family_id}/accommodations/reservations/full_list`, | ||||
|       }) | ||||
|     ).data; | ||||
|  | ||||
|     return new AccommodationsReservationsList(data); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the reservations of a given time interval for an accommodation | ||||
|    */ | ||||
|   static async ReservationsForInterval( | ||||
|     family: Family, | ||||
|     accommodation: Accommodation, | ||||
|     start: number, | ||||
|     end: number | ||||
|   ): Promise<AccommodationsReservationsList> { | ||||
|     const data = ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/family/${family.family_id}/accommodations/reservations/accommodation/${accommodation.id}/for_interval?start=${start}&end=${end}`, | ||||
|       }) | ||||
|     ).data; | ||||
|  | ||||
|     return new AccommodationsReservationsList(data); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update a reservation | ||||
|    */ | ||||
|   static async Update( | ||||
|     family: Family, | ||||
|     r: UpdateAccommodationReservation | ||||
|   ): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "PATCH", | ||||
|       uri: `/family/${family.family_id}/accommodations/reservation/${r.reservation_id}`, | ||||
|       jsonData: { | ||||
|         start: r.start, | ||||
|         end: r.end, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete a reservation | ||||
|    */ | ||||
|   static async Delete(r: AccommodationReservation): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "DELETE", | ||||
|       uri: `/family/${r.family_id}/accommodations/reservation/${r.id}`, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate or reject a reservation request | ||||
|    */ | ||||
|   static async Validate( | ||||
|     r: AccommodationReservation, | ||||
|     accept: boolean | ||||
|   ): Promise<ValidateResaResult> { | ||||
|     const res = await APIClient.exec({ | ||||
|       method: "POST", | ||||
|       uri: `/family/${r.family_id}/accommodations/reservation/${r.id}/validate`, | ||||
|       jsonData: { | ||||
|         validate: accept, | ||||
|       }, | ||||
|       allowFail: true, | ||||
|     }); | ||||
|  | ||||
|     if (res.status >= 200 && res.status <= 299) | ||||
|       return ValidateResaResult.Success; | ||||
|     if (res.status === 409) return ValidateResaResult.Conflict; | ||||
|     return ValidateResaResult.Error; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,92 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogTitle, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { NewCalendarURL } from "../../api/accommodations/AccommodationsCalendarURLApi"; | ||||
| import { checkConstraint } from "../../utils/form_utils"; | ||||
| import { useAccommodations } from "../../widgets/accommodations/BaseAccommodationsRoute"; | ||||
| import { PropEdit } from "../../widgets/forms/PropEdit"; | ||||
| import { PropSelect } from "../../widgets/forms/PropSelect"; | ||||
|  | ||||
| export function CreateAccommodationCalendarURLDialog(p: { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmitted: (c: NewCalendarURL) => void; | ||||
| }): React.ReactElement { | ||||
|   const [calendar, setCalendar] = React.useState<NewCalendarURL>({ name: "" }); | ||||
|  | ||||
|   const accommodations = useAccommodations(); | ||||
|  | ||||
|   const nameErr = checkConstraint( | ||||
|     ServerApi.Config.constraints.accommodation_calendar_name_len, | ||||
|     calendar?.name | ||||
|   ); | ||||
|  | ||||
|   const clearForm = () => { | ||||
|     setCalendar({ name: "" }); | ||||
|   }; | ||||
|  | ||||
|   const cancel = () => { | ||||
|     clearForm(); | ||||
|     p.onClose(); | ||||
|   }; | ||||
|  | ||||
|   const submit = async () => { | ||||
|     clearForm(); | ||||
|     p.onSubmitted(calendar!); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={p.open} onClose={cancel}> | ||||
|       <DialogTitle>Création d'un calendrier</DialogTitle> | ||||
|       <DialogContent style={{ display: "flex", flexDirection: "column" }}> | ||||
|         <PropEdit | ||||
|           editable | ||||
|           label="Nom" | ||||
|           value={calendar?.name} | ||||
|           onValueChange={(s) => | ||||
|             setCalendar((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 name: s!, | ||||
|               }; | ||||
|             }) | ||||
|           } | ||||
|           size={ServerApi.Config.constraints.accommodation_calendar_name_len} | ||||
|           helperText={nameErr} | ||||
|         /> | ||||
|  | ||||
|         <PropSelect | ||||
|           editing | ||||
|           label="Logement ciblé" | ||||
|           onValueChange={(v) => { | ||||
|             setCalendar((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 accommodation_id: v !== "A" && v ? Number(v) : undefined, | ||||
|               }; | ||||
|             }); | ||||
|           }} | ||||
|           options={[ | ||||
|             { label: "Tous les logements", value: "A" }, | ||||
|             ...accommodations.accommodations.fullList.map((a) => { | ||||
|               return { label: a.name, value: a.id.toString() }; | ||||
|             }), | ||||
|           ]} | ||||
|           value={calendar.accommodation_id?.toString() ?? "A"} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={cancel}>Annuler</Button> | ||||
|         <Button onClick={submit} disabled={!!nameErr}> | ||||
|           Créer | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,72 @@ | ||||
| import ContentCopyIcon from "@mui/icons-material/ContentCopy"; | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogContentText, | ||||
|   DialogTitle, | ||||
|   FormControl, | ||||
|   IconButton, | ||||
|   InputAdornment, | ||||
|   OutlinedInput, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import QRCode from "react-qr-code"; | ||||
| import { | ||||
|   AccommodationCalendarURL, | ||||
|   AccommodationsCalendarURLApi, | ||||
| } from "../../api/accommodations/AccommodationsCalendarURLApi"; | ||||
| import { CopyToClipboard } from "../../widgets/CopyToClipboard"; | ||||
|  | ||||
| export function InstallCalendarDialog(p: { | ||||
|   cal?: AccommodationCalendarURL; | ||||
|   onClose: () => void; | ||||
| }): React.ReactElement { | ||||
|   if (!p.cal) return <></>; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={true} onClose={p.onClose}> | ||||
|       <DialogTitle>Installation du calendrier</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           <Typography> | ||||
|             Afin d'installer le calendrier <i>{p.cal.name}</i> sur votre | ||||
|             appareil, veuillez utiliser l'URL suivante : | ||||
|           </Typography> | ||||
|           <br /> | ||||
|           <FormControl fullWidth variant="outlined"> | ||||
|             <OutlinedInput | ||||
|               value={AccommodationsCalendarURLApi.CalendarURL(p.cal!)} | ||||
|               endAdornment={ | ||||
|                 <InputAdornment position="end"> | ||||
|                   <CopyToClipboard | ||||
|                     content={AccommodationsCalendarURLApi.CalendarURL(p.cal!)} | ||||
|                   > | ||||
|                     <IconButton> | ||||
|                       <ContentCopyIcon /> | ||||
|                     </IconButton> | ||||
|                   </CopyToClipboard> | ||||
|                 </InputAdornment> | ||||
|               } | ||||
|             /> | ||||
|             <div | ||||
|               style={{ | ||||
|                 margin: "20px auto", | ||||
|                 padding: "20px", | ||||
|                 backgroundColor: "white", | ||||
|               }} | ||||
|             > | ||||
|               <QRCode | ||||
|                 value={AccommodationsCalendarURLApi.CalendarURL(p.cal!)} | ||||
|               /> | ||||
|             </div> | ||||
|           </FormControl> | ||||
|         </DialogContentText> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={p.onClose}>Fermer</Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,155 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogTitle, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { UpdateAccommodation } from "../../api/accommodations/AccommodationListApi"; | ||||
| import { checkConstraint } from "../../utils/form_utils"; | ||||
| import { PropCheckbox } from "../../widgets/forms/PropCheckbox"; | ||||
| import { PropEdit } from "../../widgets/forms/PropEdit"; | ||||
| import { PropColorPicker } from "../../widgets/forms/PropColorPicker"; | ||||
|  | ||||
| export function UpdateAccommodationDialog(p: { | ||||
|   open: boolean; | ||||
|   create: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmitted: (c: UpdateAccommodation) => void; | ||||
|   accommodation: UpdateAccommodation | undefined; | ||||
| }): React.ReactElement { | ||||
|   const [accommodation, setAccommodation] = React.useState< | ||||
|     UpdateAccommodation | undefined | ||||
|   >(); | ||||
|  | ||||
|   const nameErr = checkConstraint( | ||||
|     ServerApi.Config.constraints.accommodation_name_len, | ||||
|     accommodation?.name | ||||
|   ); | ||||
|   const descriptionErr = checkConstraint( | ||||
|     ServerApi.Config.constraints.accommodation_description_len, | ||||
|     accommodation?.description | ||||
|   ); | ||||
|  | ||||
|   const clearForm = () => { | ||||
|     setAccommodation(undefined); | ||||
|   }; | ||||
|  | ||||
|   const cancel = () => { | ||||
|     clearForm(); | ||||
|     p.onClose(); | ||||
|   }; | ||||
|  | ||||
|   const submit = async () => { | ||||
|     clearForm(); | ||||
|     p.onSubmitted(accommodation!); | ||||
|   }; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (!accommodation) setAccommodation(p.accommodation); | ||||
|   }, [p.open, p.accommodation]); | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={p.open} onClose={cancel}> | ||||
|       <DialogTitle> | ||||
|         {p.create ? "Création" : "Mise à jour"} d'un logement | ||||
|       </DialogTitle> | ||||
|       <DialogContent style={{ display: "flex", flexDirection: "column" }}> | ||||
|         <PropEdit | ||||
|           editable | ||||
|           label="Nom" | ||||
|           value={accommodation?.name} | ||||
|           onValueChange={(s) => | ||||
|             setAccommodation((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 name: s!, | ||||
|               }; | ||||
|             }) | ||||
|           } | ||||
|           size={ServerApi.Config.constraints.accommodation_name_len} | ||||
|           helperText={nameErr} | ||||
|         /> | ||||
|  | ||||
|         <PropEdit | ||||
|           editable | ||||
|           label="Description" | ||||
|           value={accommodation?.description} | ||||
|           onValueChange={(s) => | ||||
|             setAccommodation((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 description: s!, | ||||
|               }; | ||||
|             }) | ||||
|           } | ||||
|           size={ServerApi.Config.constraints.accommodation_description_len} | ||||
|           helperText={descriptionErr} | ||||
|         /> | ||||
|  | ||||
|         <PropColorPicker | ||||
|           editable | ||||
|           label="Couleur" | ||||
|           value={accommodation?.color} | ||||
|           onChange={(s) => | ||||
|             setAccommodation((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 color: s!, | ||||
|               }; | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <PropCheckbox | ||||
|           editable | ||||
|           label="Ouvert aux réservations" | ||||
|           checked={accommodation?.open_to_reservations === true} | ||||
|           onValueChange={(c) => | ||||
|             setAccommodation((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 open_to_reservations: c, | ||||
|               }; | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <Tooltip | ||||
|           title={ | ||||
|             "Permet de spécifier si un administrateur de la famille doit valider manuellement les demandes de réservation pour qu'elles soient validées" | ||||
|           } | ||||
|         > | ||||
|           <PropCheckbox | ||||
|             checkboxAlwaysVisible | ||||
|             editable={accommodation?.open_to_reservations === true} | ||||
|             label="Validation des réservations par un administrateur requise" | ||||
|             checked={accommodation?.need_validation === true} | ||||
|             onValueChange={(c) => | ||||
|               setAccommodation((a) => { | ||||
|                 return { | ||||
|                   ...a!, | ||||
|                   need_validation: c, | ||||
|                 }; | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|         </Tooltip> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={cancel}>Annuler</Button> | ||||
|         <Button | ||||
|           onClick={submit} | ||||
|           disabled={ | ||||
|             !!nameErr || (!!accommodation?.description && !!descriptionErr) | ||||
|           } | ||||
|         > | ||||
|           {p.create ? "Créer" : "Mettre à jour"} | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,192 @@ | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogTitle, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { | ||||
|   AccommodationReservation, | ||||
|   AccommodationsReservationsApi, | ||||
|   UpdateAccommodationReservation, | ||||
| } from "../../api/accommodations/AccommodationsReservationsApi"; | ||||
| import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; | ||||
| import { fmtUnixDate } from "../../utils/time_utils"; | ||||
| import { useFamily } from "../../widgets/BaseFamilyRoute"; | ||||
| import { useAccommodations } from "../../widgets/accommodations/BaseAccommodationsRoute"; | ||||
| import { PropDateInput } from "../../widgets/forms/PropDateInput"; | ||||
| import { PropSelect } from "../../widgets/forms/PropSelect"; | ||||
|  | ||||
| export function UpdateReservationDialog(p: { | ||||
|   open: boolean; | ||||
|   create: boolean; | ||||
|   reservation?: UpdateAccommodationReservation; | ||||
|   onClose: () => void; | ||||
|   onSubmitted: (c: UpdateAccommodationReservation) => void; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|  | ||||
|   const family = useFamily(); | ||||
|   const accommodations = useAccommodations(); | ||||
|  | ||||
|   const [reservation, setReservation] = React.useState< | ||||
|     UpdateAccommodationReservation | undefined | ||||
|   >(); | ||||
|  | ||||
|   const [conflicts, setConflicts] = React.useState< | ||||
|     AccommodationReservation[] | undefined | ||||
|   >(undefined); | ||||
|  | ||||
|   const clearForm = () => { | ||||
|     setReservation(undefined); | ||||
|   }; | ||||
|  | ||||
|   const cancel = () => { | ||||
|     clearForm(); | ||||
|     p.onClose(); | ||||
|   }; | ||||
|  | ||||
|   const submit = async () => { | ||||
|     clearForm(); | ||||
|     p.onSubmitted(reservation!); | ||||
|   }; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (!reservation) setReservation(p.reservation); | ||||
|   }, [p.open, p.reservation]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     setConflicts(undefined); | ||||
|     (async () => { | ||||
|       try { | ||||
|         if ( | ||||
|           !reservation || | ||||
|           reservation.accommodation_id < 1 || | ||||
|           reservation.start < 1 || | ||||
|           reservation.start > reservation.end | ||||
|         ) { | ||||
|           setConflicts([]); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         setConflicts( | ||||
|           ( | ||||
|             await AccommodationsReservationsApi.ReservationsForInterval( | ||||
|               family.family, | ||||
|               accommodations.accommodations.get(reservation.accommodation_id)!, | ||||
|               reservation.start, | ||||
|               reservation.end | ||||
|             ) | ||||
|           ).filter( | ||||
|             (r) => | ||||
|               r.id !== p.reservation?.reservation_id && r.validated !== false | ||||
|           ) | ||||
|         ); | ||||
|       } catch (e) { | ||||
|         console.error(e); | ||||
|         alert( | ||||
|           "Echec de la vérification de la présence de conflits de calendrier !" | ||||
|         ); | ||||
|       } | ||||
|     })(); | ||||
|   }, [ | ||||
|     p.open, | ||||
|     reservation?.accommodation_id, | ||||
|     reservation?.start, | ||||
|     reservation?.end, | ||||
|   ]); | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={p.open} onClose={cancel}> | ||||
|       <DialogTitle> | ||||
|         {p.create ? "Création" : "Mise à jour"} d'une réservation | ||||
|       </DialogTitle> | ||||
|       <DialogContent style={{ display: "flex", flexDirection: "column" }}> | ||||
|         <PropSelect | ||||
|           editing={p.create} | ||||
|           label="Logement ciblé" | ||||
|           onValueChange={(v) => { | ||||
|             setReservation((a) => { | ||||
|               return { | ||||
|                 ...a!, | ||||
|                 accommodation_id: Number(v), | ||||
|               }; | ||||
|             }); | ||||
|           }} | ||||
|           options={accommodations.accommodations.openToReservationList.map( | ||||
|             (a) => { | ||||
|               return { label: a.name, value: a.id.toString() }; | ||||
|             } | ||||
|           )} | ||||
|           value={ | ||||
|             reservation?.accommodation_id === -1 | ||||
|               ? "" | ||||
|               : reservation?.accommodation_id?.toString() | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <PropDateInput | ||||
|           editable | ||||
|           label="Date de début" | ||||
|           value={reservation?.start} | ||||
|           onChange={(s) => { | ||||
|             setReservation((r) => { | ||||
|               return { ...r!, start: s ?? -1 }; | ||||
|             }); | ||||
|           }} | ||||
|           minDate={Math.floor(new Date().getTime() / 1000) - 3600 * 24 * 60} | ||||
|           canSetMiddleDay | ||||
|         /> | ||||
|  | ||||
|         <PropDateInput | ||||
|           editable | ||||
|           label="Date de fin" | ||||
|           value={reservation?.end} | ||||
|           lastSecOfDay={true} | ||||
|           onChange={(s) => { | ||||
|             setReservation((r) => { | ||||
|               return { ...r!, end: s ?? -1 }; | ||||
|             }); | ||||
|           }} | ||||
|           minDate={reservation?.start} | ||||
|           canSetMiddleDay | ||||
|         /> | ||||
|  | ||||
|         {conflicts && conflicts.length > 0 && ( | ||||
|           <Alert severity="error"> | ||||
|             <p> | ||||
|               Cette réservation est en conflit avec d'autres réservations sur | ||||
|               les intervalles suivants : | ||||
|             </p> | ||||
|             <ul> | ||||
|               {conflicts.map((c, num) => ( | ||||
|                 <li key={num}> | ||||
|                   Réservation du {fmtUnixDate(c.reservation_start)} au{" "} | ||||
|                   {fmtUnixDate(c.reservation_end)} | ||||
|                 </li> | ||||
|               ))} | ||||
|             </ul> | ||||
|           </Alert> | ||||
|         )} | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={cancel}>Annuler</Button> | ||||
|         <Button | ||||
|           onClick={submit} | ||||
|           disabled={ | ||||
|             !( | ||||
|               (reservation?.accommodation_id ?? -1) > 0 && | ||||
|               (reservation?.start ?? -1) > 0 && | ||||
|               (reservation?.end ?? -1) > (reservation?.start ?? 0) && | ||||
|               (conflicts?.length ?? 0) === 0 | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           {p.create ? "Créer" : "Mettre à jour"} | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,52 @@ | ||||
| import React, { PropsWithChildren } from "react"; | ||||
| import { NewCalendarURL } from "../../../api/accommodations/AccommodationsCalendarURLApi"; | ||||
| import { CreateAccommodationCalendarURLDialog } from "../../../dialogs/accommodations/CreateAccommodationCalendarURLDialog"; | ||||
|  | ||||
| type DialogContext = () => Promise<NewCalendarURL | undefined>; | ||||
|  | ||||
| const DialogContextK = React.createContext<DialogContext | null>(null); | ||||
|  | ||||
| export function CreateAccommodationCalendarURLDialogProvider( | ||||
|   p: PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   const [open, setOpen] = React.useState(false); | ||||
|  | ||||
|   const cb = React.useRef<null | ((a: NewCalendarURL | undefined) => void)>( | ||||
|     null | ||||
|   ); | ||||
|  | ||||
|   const handleClose = (res?: NewCalendarURL) => { | ||||
|     setOpen(false); | ||||
|  | ||||
|     if (cb.current !== null) cb.current(res); | ||||
|     cb.current = null; | ||||
|   }; | ||||
|  | ||||
|   const hook: DialogContext = () => { | ||||
|     setOpen(true); | ||||
|  | ||||
|     return new Promise((res) => { | ||||
|       cb.current = res; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogContextK.Provider value={hook}> | ||||
|         {p.children} | ||||
|       </DialogContextK.Provider> | ||||
|  | ||||
|       {open && ( | ||||
|         <CreateAccommodationCalendarURLDialog | ||||
|           open={open} | ||||
|           onClose={handleClose} | ||||
|           onSubmitted={handleClose} | ||||
|         /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useCreateAccommodationCalendarURL(): DialogContext { | ||||
|   return React.useContext(DialogContextK)!; | ||||
| } | ||||
| @@ -0,0 +1,44 @@ | ||||
| import React, { PropsWithChildren } from "react"; | ||||
| import { AccommodationCalendarURL } from "../../../api/accommodations/AccommodationsCalendarURLApi"; | ||||
| import { InstallCalendarDialog } from "../../../dialogs/accommodations/InstallCalendarDialog"; | ||||
|  | ||||
| type DialogContext = (cal: AccommodationCalendarURL) => Promise<void>; | ||||
|  | ||||
| const DialogContextK = React.createContext<DialogContext | null>(null); | ||||
|  | ||||
| export function InstallCalendarDialogProvider( | ||||
|   p: PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   const [cal, setCal] = React.useState<AccommodationCalendarURL | undefined>(); | ||||
|  | ||||
|   const cb = React.useRef<null | (() => void)>(null); | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setCal(undefined); | ||||
|  | ||||
|     if (cb.current !== null) cb.current(); | ||||
|     cb.current = null; | ||||
|   }; | ||||
|  | ||||
|   const hook: DialogContext = (c) => { | ||||
|     setCal(c); | ||||
|  | ||||
|     return new Promise((res) => { | ||||
|       cb.current = res; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogContextK.Provider value={hook}> | ||||
|         {p.children} | ||||
|       </DialogContextK.Provider> | ||||
|  | ||||
|       {cal && <InstallCalendarDialog cal={cal} onClose={handleClose} />} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useInstallCalendarDialog(): DialogContext { | ||||
|   return React.useContext(DialogContextK)!; | ||||
| } | ||||
| @@ -0,0 +1,64 @@ | ||||
| import React, { PropsWithChildren } from "react"; | ||||
| import { UpdateAccommodation } from "../../../api/accommodations/AccommodationListApi"; | ||||
| import { UpdateAccommodationDialog } from "../../../dialogs/accommodations/UpdateAccommodationDialog"; | ||||
|  | ||||
| type DialogContext = ( | ||||
|   accommodation: UpdateAccommodation, | ||||
|   create: boolean | ||||
| ) => Promise<UpdateAccommodation | undefined>; | ||||
|  | ||||
| const DialogContextK = React.createContext<DialogContext | null>(null); | ||||
|  | ||||
| export function UpdateAccommodationDialogProvider( | ||||
|   p: PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   const [open, setOpen] = React.useState(false); | ||||
|  | ||||
|   const [accommodation, setAccommodation] = React.useState< | ||||
|     UpdateAccommodation | undefined | ||||
|   >(undefined); | ||||
|   const [create, setCreate] = React.useState(false); | ||||
|  | ||||
|   const cb = React.useRef< | ||||
|     null | ((a: UpdateAccommodation | undefined) => void) | ||||
|   >(null); | ||||
|  | ||||
|   const handleClose = (res?: UpdateAccommodation) => { | ||||
|     setOpen(false); | ||||
|  | ||||
|     if (cb.current !== null) cb.current(res); | ||||
|     cb.current = null; | ||||
|   }; | ||||
|  | ||||
|   const hook: DialogContext = (accommodation, create) => { | ||||
|     setAccommodation(accommodation); | ||||
|     setCreate(create); | ||||
|     setOpen(true); | ||||
|  | ||||
|     return new Promise((res) => { | ||||
|       cb.current = res; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogContextK.Provider value={hook}> | ||||
|         {p.children} | ||||
|       </DialogContextK.Provider> | ||||
|  | ||||
|       {open && ( | ||||
|         <UpdateAccommodationDialog | ||||
|           open={open} | ||||
|           accommodation={accommodation} | ||||
|           create={create} | ||||
|           onClose={handleClose} | ||||
|           onSubmitted={handleClose} | ||||
|         /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useUpdateAccommodation(): DialogContext { | ||||
|   return React.useContext(DialogContextK)!; | ||||
| } | ||||
| @@ -0,0 +1,64 @@ | ||||
| import React, { PropsWithChildren } from "react"; | ||||
| import { UpdateAccommodationReservation } from "../../../api/accommodations/AccommodationsReservationsApi"; | ||||
| import { UpdateReservationDialog } from "../../../dialogs/accommodations/UpdateReservationDialog"; | ||||
|  | ||||
| type DialogContext = ( | ||||
|   reservation: UpdateAccommodationReservation, | ||||
|   create: boolean | ||||
| ) => Promise<UpdateAccommodationReservation | undefined>; | ||||
|  | ||||
| const DialogContextK = React.createContext<DialogContext | null>(null); | ||||
|  | ||||
| export function UpdateReservationDialogProvider( | ||||
|   p: PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   const [open, setOpen] = React.useState(false); | ||||
|  | ||||
|   const [reservation, setReservation] = React.useState< | ||||
|     UpdateAccommodationReservation | undefined | ||||
|   >(undefined); | ||||
|   const [create, setCreate] = React.useState(false); | ||||
|  | ||||
|   const cb = React.useRef< | ||||
|     null | ((a: UpdateAccommodationReservation | undefined) => void) | ||||
|   >(null); | ||||
|  | ||||
|   const handleClose = (res?: UpdateAccommodationReservation) => { | ||||
|     setOpen(false); | ||||
|  | ||||
|     if (cb.current !== null) cb.current(res); | ||||
|     cb.current = null; | ||||
|   }; | ||||
|  | ||||
|   const hook: DialogContext = (accommodation, create) => { | ||||
|     setReservation(accommodation); | ||||
|     setCreate(create); | ||||
|     setOpen(true); | ||||
|  | ||||
|     return new Promise((res) => { | ||||
|       cb.current = res; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogContextK.Provider value={hook}> | ||||
|         {p.children} | ||||
|       </DialogContextK.Provider> | ||||
|  | ||||
|       {open && ( | ||||
|         <UpdateReservationDialog | ||||
|           open={open} | ||||
|           reservation={reservation} | ||||
|           create={create} | ||||
|           onClose={handleClose} | ||||
|           onSubmitted={handleClose} | ||||
|         /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useUpdateAccommodationReservation(): DialogContext { | ||||
|   return React.useContext(DialogContextK)!; | ||||
| } | ||||
| @@ -71,6 +71,9 @@ function FamilySettingsCard(): React.ReactElement { | ||||
|   const [enableGenealogy, setEnableGenealogy] = React.useState( | ||||
|     family.family.enable_genealogy | ||||
|   ); | ||||
|   const [enableAccommodations, setEnableAccommodations] = React.useState( | ||||
|     family.family.enable_accommodations | ||||
|   ); | ||||
|  | ||||
|   const canEdit = family.family.is_admin; | ||||
|  | ||||
| @@ -86,6 +89,7 @@ function FamilySettingsCard(): React.ReactElement { | ||||
|         id: family.family.family_id, | ||||
|         name: newName, | ||||
|         enable_genealogy: enableGenealogy, | ||||
|         enable_accommodations: enableAccommodations, | ||||
|       }); | ||||
|  | ||||
|       family.reloadFamilyInfo(); | ||||
| @@ -118,14 +122,12 @@ function FamilySettingsCard(): React.ReactElement { | ||||
|             label="Identifiant" | ||||
|             value={family.family.family_id} | ||||
|           /> | ||||
|  | ||||
|           <TextField | ||||
|             disabled | ||||
|             fullWidth | ||||
|             label="Création de la famille" | ||||
|             value={formatDate(family.family.time_create)} | ||||
|           /> | ||||
|  | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Nom de la famille" | ||||
| @@ -136,7 +138,6 @@ function FamilySettingsCard(): React.ReactElement { | ||||
|               maxLength: ServerApi.Config.constraints.family_name_len.max, | ||||
|             }} | ||||
|           /> | ||||
|  | ||||
|           <FormControlLabel | ||||
|             disabled={!canEdit} | ||||
|             control={ | ||||
| @@ -147,6 +148,16 @@ function FamilySettingsCard(): React.ReactElement { | ||||
|             } | ||||
|             label="Activer le module de généalogie" | ||||
|           /> | ||||
|           <FormControlLabel | ||||
|             disabled={!canEdit} | ||||
|             control={ | ||||
|               <Switch | ||||
|                 checked={enableAccommodations} | ||||
|                 onChange={(_e, c) => setEnableAccommodations(c)} | ||||
|               /> | ||||
|             } | ||||
|             label="Activer le module de réservation de logements" | ||||
|           /> | ||||
|         </Box> | ||||
|       </CardContent> | ||||
|       <CardActions> | ||||
|   | ||||
| @@ -0,0 +1,21 @@ | ||||
| import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle"; | ||||
| import { useAccommodations } from "../../../widgets/accommodations/BaseAccommodationsRoute"; | ||||
|  | ||||
| export function AccommodationsHomeRoute(): React.ReactElement { | ||||
|   const accommodations = useAccommodations(); | ||||
|   return ( | ||||
|     <> | ||||
|       <FamilyPageTitle title="Réservation de logements" /> | ||||
|       <div style={{ margin: "20px" }}> | ||||
|         <p> | ||||
|           Depuis cette section de l'application, vous pouvez effectuer des | ||||
|           réservations de logements. | ||||
|         </p> | ||||
|         <p> </p> | ||||
|         <p> | ||||
|           Nombre de logements définis : {accommodations.accommodations.size} | ||||
|         </p> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,598 @@ | ||||
| import { DateSelectArg, EventClickArg } from "@fullcalendar/core"; | ||||
| import frLocale from "@fullcalendar/core/locales/fr"; | ||||
| import dayGridPlugin from "@fullcalendar/daygrid"; | ||||
| import interactionPlugin from "@fullcalendar/interaction"; | ||||
| import listPlugin from "@fullcalendar/list"; | ||||
| import FullCalendar from "@fullcalendar/react"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | ||||
| import RuleIcon from "@mui/icons-material/Rule"; | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Card, | ||||
|   CardActions, | ||||
|   CardContent, | ||||
|   CardHeader, | ||||
|   Checkbox, | ||||
|   FormControl, | ||||
|   FormControlLabel, | ||||
|   FormGroup, | ||||
|   FormLabel, | ||||
|   IconButton, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
|   Popover, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import { red } from "@mui/material/colors"; | ||||
| import React from "react"; | ||||
| import { FamilyApi, FamilyUser } from "../../../api/FamilyApi"; | ||||
| import { Accommodation } from "../../../api/accommodations/AccommodationListApi"; | ||||
| import { | ||||
|   AccommodationReservation, | ||||
|   AccommodationsReservationsApi, | ||||
|   AccommodationsReservationsList, | ||||
|   ValidateResaResult, | ||||
| } from "../../../api/accommodations/AccommodationsReservationsApi"; | ||||
| import { useAlert } from "../../../hooks/context_providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider"; | ||||
| import { useLoadingMessage } from "../../../hooks/context_providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../../../hooks/context_providers/SnackbarProvider"; | ||||
| import { useUpdateAccommodationReservation } from "../../../hooks/context_providers/accommodations/UpdateReservationDialogProvider"; | ||||
| import { | ||||
|   fmtUnixDate, | ||||
|   fmtUnixDateFullCalendar, | ||||
| } from "../../../utils/time_utils"; | ||||
| import { AsyncWidget } from "../../../widgets/AsyncWidget"; | ||||
| import { useUser } from "../../../widgets/BaseAuthenticatedPage"; | ||||
| import { useFamily } from "../../../widgets/BaseFamilyRoute"; | ||||
| import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle"; | ||||
| import { useAccommodations } from "../../../widgets/accommodations/BaseAccommodationsRoute"; | ||||
|  | ||||
| export function AccommodationsReservationsRoute(): React.ReactElement { | ||||
|   const snackbar = useSnackbar(); | ||||
|   const alert = useAlert(); | ||||
|   const confirm = useConfirm(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const loadKey = React.useRef(1); | ||||
|  | ||||
|   const user = useUser(); | ||||
|   const family = useFamily(); | ||||
|   const accommodations = useAccommodations(); | ||||
|   const updateReservation = useUpdateAccommodationReservation(); | ||||
|  | ||||
|   const [reservations, setReservations] = React.useState< | ||||
|     AccommodationsReservationsList | undefined | ||||
|   >(); | ||||
|   const [users, setUsers] = React.useState<FamilyUser[] | null>(null); | ||||
|  | ||||
|   const [showValidated, setShowValidated] = React.useState(true); | ||||
|   const [showRejected, setShowRejected] = React.useState(true); | ||||
|   const [showPending, setShowPending] = React.useState(true); | ||||
|  | ||||
|   const [hiddenPeople, setHiddenPeople] = React.useState<Set<number>>( | ||||
|     new Set() | ||||
|   ); | ||||
|   const [hiddenAccommodations, setHiddenAccommodations] = React.useState< | ||||
|     Set<number> | ||||
|   >(new Set()); | ||||
|  | ||||
|   const eventPopupAnchor = React.useRef<HTMLDivElement>(null); | ||||
|   const [activeEvent, setActiveEvent] = React.useState< | ||||
|     | undefined | ||||
|     | { | ||||
|         user: FamilyUser; | ||||
|         accommodation: Accommodation; | ||||
|         reservation: AccommodationReservation; | ||||
|  | ||||
|         x: number; | ||||
|         y: number; | ||||
|         w: number; | ||||
|         h: number; | ||||
|       } | ||||
|   >(); | ||||
|  | ||||
|   const [validateResaAnchorEl, setValidateResaAnchorEl] = | ||||
|     React.useState<null | HTMLElement>(null); | ||||
|  | ||||
|   const load = async () => { | ||||
|     setReservations( | ||||
|       await AccommodationsReservationsApi.FullListOfFamily(family.family) | ||||
|     ); | ||||
|     setUsers(await FamilyApi.GetUsersList(family.family.family_id)); | ||||
|   }; | ||||
|  | ||||
|   const reload = async () => { | ||||
|     loadKey.current += 1; | ||||
|     setUsers(null); | ||||
|   }; | ||||
|  | ||||
|   const visibleReservations = React.useMemo(() => { | ||||
|     return reservations?.filter((r) => { | ||||
|       if (!showValidated && r.validated === true) return false; | ||||
|       if (!showPending && r.validated === null) return false; | ||||
|       if (!showRejected && r.validated === false) return false; | ||||
|       if (hiddenPeople.has(r.user_id)) return false; | ||||
|       if (hiddenAccommodations.has(r.accommodation_id)) return false; | ||||
|       return true; | ||||
|     }); | ||||
|   }, [ | ||||
|     showValidated, | ||||
|     showRejected, | ||||
|     showPending, | ||||
|     hiddenPeople, | ||||
|     hiddenAccommodations, | ||||
|     reservations, | ||||
|   ]); | ||||
|  | ||||
|   const onSelect = async (d: DateSelectArg) => { | ||||
|     try { | ||||
|       const resa = await updateReservation( | ||||
|         { | ||||
|           accommodation_id: -1, | ||||
|           start: Math.floor(d.start.getTime() / 1000), | ||||
|           end: Math.floor(d.end.getTime() / 1000), | ||||
|         }, | ||||
|         true | ||||
|       ); | ||||
|  | ||||
|       if (!resa) return; | ||||
|  | ||||
|       loadingMessage.show("Création de la réservation en cours..."); | ||||
|  | ||||
|       await AccommodationsReservationsApi.Create(family.family, resa); | ||||
|  | ||||
|       reload(); | ||||
|       snackbar("La réservation a été créée avec succès !"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to create a reservation!", e); | ||||
|       alert("Échec de la création de la réservation!"); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const onEventClick = (ev: EventClickArg) => { | ||||
|     const id: number = ev.event.extendedProps.id; | ||||
|     const resa = reservations?.get(id)!; | ||||
|     const acc = accommodations.accommodations.get(resa.accommodation_id)!; | ||||
|  | ||||
|     const user = users?.find((u) => u.user_id === resa.user_id); | ||||
|  | ||||
|     if (!user) { | ||||
|       console.error(`User ${resa.user_id} not found!`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const loc = ev.el.getBoundingClientRect(); | ||||
|     setActiveEvent({ | ||||
|       reservation: resa, | ||||
|       accommodation: acc, | ||||
|       user: user, | ||||
|  | ||||
|       x: loc.left, | ||||
|       y: loc.top, | ||||
|       w: loc.width, | ||||
|       h: loc.height, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const respondToResaRequest = async ( | ||||
|     r: AccommodationReservation, | ||||
|     validate: boolean | ||||
|   ) => { | ||||
|     try { | ||||
|       loadingMessage.show("Validation de la réservation en cours..."); | ||||
|  | ||||
|       setValidateResaAnchorEl(null); | ||||
|       setActiveEvent(undefined); | ||||
|  | ||||
|       const res = await AccommodationsReservationsApi.Validate(r, validate); | ||||
|  | ||||
|       if (res === ValidateResaResult.Conflict) { | ||||
|         throw new Error( | ||||
|           "The reservation is in conflict with other reservations!" | ||||
|         ); | ||||
|       } else if (res === ValidateResaResult.Error) { | ||||
|         throw new Error("Failed to validate the reservation!"); | ||||
|       } | ||||
|  | ||||
|       reload(); | ||||
|       snackbar("La réservation a été mise à jour avec succès !"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to respond to reservation request!", e); | ||||
|       alert(`Echec de l'enregistrement de la réponse à la réservation ! ${e}`); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const validateReservation = async (r: AccommodationReservation) => { | ||||
|     respondToResaRequest(r, true); | ||||
|   }; | ||||
|  | ||||
|   const rejectReservation = async (r: AccommodationReservation) => { | ||||
|     if ( | ||||
|       !(await confirm( | ||||
|         "Voulez-vous vraiment rejeter cette demande de réservation ?" | ||||
|       )) | ||||
|     ) | ||||
|       return; | ||||
|     respondToResaRequest(r, false); | ||||
|   }; | ||||
|  | ||||
|   const changeReservation = async (r: AccommodationReservation) => { | ||||
|     try { | ||||
|       const ac = accommodations.accommodations.get(r.accommodation_id); | ||||
|       if ( | ||||
|         ac?.need_validation && | ||||
|         !(await confirm( | ||||
|           "Voulez-vous vraiment changer cette réservation ? Celle-ci devra être de nouveau validée !" | ||||
|         )) | ||||
|       ) | ||||
|         return; | ||||
|  | ||||
|       const newResa = await updateReservation( | ||||
|         { | ||||
|           reservation_id: r.id, | ||||
|           accommodation_id: r.accommodation_id, | ||||
|           start: r.reservation_start, | ||||
|           end: r.reservation_end, | ||||
|         }, | ||||
|         false | ||||
|       ); | ||||
|  | ||||
|       if (!newResa) return; | ||||
|  | ||||
|       setActiveEvent(undefined); | ||||
|       loadingMessage.show("Mise à jour de la réservation en cours..."); | ||||
|  | ||||
|       await AccommodationsReservationsApi.Update(family.family, newResa); | ||||
|  | ||||
|       reload(); | ||||
|       snackbar("La réservation a été mise à jour avec succès !"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to update a reservation!", e); | ||||
|       alert("Échec de la mise à jour de la réservation!"); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteReservation = async (r: AccommodationReservation) => { | ||||
|     try { | ||||
|       if ( | ||||
|         !(await confirm( | ||||
|           "Voulez-vous vraiment supprimer cette réservation ? L'opération n'est pas réversible !" | ||||
|         )) | ||||
|       ) | ||||
|         return; | ||||
|  | ||||
|       setActiveEvent(undefined); | ||||
|       loadingMessage.show("Suppression de la réservation en cours..."); | ||||
|  | ||||
|       await AccommodationsReservationsApi.Delete(r); | ||||
|  | ||||
|       reload(); | ||||
|       snackbar("La réservation a été supprimée avec succès !"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to delete a reservation!", e); | ||||
|       alert("Échec de la suppression de la réservation!"); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <FamilyPageTitle title="Réservations" /> | ||||
|       <AsyncWidget | ||||
|         loadKey={loadKey.current} | ||||
|         load={load} | ||||
|         errMsg="Echec du chargement de la liste des réservations !" | ||||
|         build={() => ( | ||||
|           <div style={{ display: "flex", flexDirection: "row" }}> | ||||
|             <div style={{ flex: 1, maxWidth: "250px", marginRight: "20px" }}> | ||||
|               <Alert severity="info"> | ||||
|                 Cliquez sur le calendrier pour créer une réservation. | ||||
|               </Alert> | ||||
|  | ||||
|               {/* Invitation status */} | ||||
|               <FormControl | ||||
|                 sx={{ m: 3 }} | ||||
|                 component="fieldset" | ||||
|                 variant="standard" | ||||
|               > | ||||
|                 <FormLabel component="legend">Status</FormLabel> | ||||
|                 <FormGroup> | ||||
|                   <FormControlLabel | ||||
|                     control={ | ||||
|                       <Checkbox | ||||
|                         checked={showValidated} | ||||
|                         onChange={(_ev, v) => setShowValidated(v)} | ||||
|                         color="success" | ||||
|                       /> | ||||
|                     } | ||||
|                     label="Validées" | ||||
|                   /> | ||||
|                   <FormControlLabel | ||||
|                     control={ | ||||
|                       <Checkbox | ||||
|                         checked={showRejected} | ||||
|                         onChange={(_ev, v) => setShowRejected(v)} | ||||
|                         color="error" | ||||
|                       /> | ||||
|                     } | ||||
|                     label="Rejetées" | ||||
|                   /> | ||||
|                   <FormControlLabel | ||||
|                     control={ | ||||
|                       <Checkbox | ||||
|                         checked={showPending} | ||||
|                         onChange={(_ev, v) => setShowPending(v)} | ||||
|                         color="info" | ||||
|                       /> | ||||
|                     } | ||||
|                     label="En attente de validation" | ||||
|                   /> | ||||
|                 </FormGroup> | ||||
|               </FormControl> | ||||
|  | ||||
|               {/* Accommodations */} | ||||
|               <FormControl | ||||
|                 sx={{ m: 3 }} | ||||
|                 component="fieldset" | ||||
|                 variant="standard" | ||||
|               > | ||||
|                 <FormLabel component="legend">Logements</FormLabel> | ||||
|                 <FormGroup> | ||||
|                   {accommodations.accommodations.fullList.map((a) => ( | ||||
|                     <FormControlLabel | ||||
|                       key={a.id} | ||||
|                       control={ | ||||
|                         <Checkbox | ||||
|                           sx={{ | ||||
|                             color: "#" + a.color, | ||||
|                             "&.Mui-checked": { | ||||
|                               color: "#" + a.color, | ||||
|                             }, | ||||
|                           }} | ||||
|                           checked={!hiddenAccommodations.has(a.id)} | ||||
|                           onChange={(_ev, v) => { | ||||
|                             if (v) hiddenAccommodations.delete(a.id); | ||||
|                             else hiddenAccommodations.add(a.id); | ||||
|                             setHiddenAccommodations( | ||||
|                               new Set(hiddenAccommodations) | ||||
|                             ); | ||||
|                           }} | ||||
|                         /> | ||||
|                       } | ||||
|                       label={a.name} | ||||
|                     /> | ||||
|                   ))} | ||||
|                 </FormGroup> | ||||
|               </FormControl> | ||||
|  | ||||
|               {/* People */} | ||||
|               <FormControl | ||||
|                 sx={{ m: 3 }} | ||||
|                 component="fieldset" | ||||
|                 variant="standard" | ||||
|               > | ||||
|                 <FormLabel component="legend">Personnes</FormLabel> | ||||
|                 <FormGroup> | ||||
|                   {users?.map((u) => ( | ||||
|                     <FormControlLabel | ||||
|                       key={u.user_id} | ||||
|                       control={ | ||||
|                         <Checkbox | ||||
|                           checked={!hiddenPeople.has(u.user_id)} | ||||
|                           onChange={(_ev, v) => { | ||||
|                             if (v) hiddenPeople.delete(u.user_id); | ||||
|                             else hiddenPeople.add(u.user_id); | ||||
|                             setHiddenPeople(new Set(hiddenPeople)); | ||||
|                           }} | ||||
|                         /> | ||||
|                       } | ||||
|                       label={u.user_name} | ||||
|                     /> | ||||
|                   ))} | ||||
|                 </FormGroup> | ||||
|               </FormControl> | ||||
|             </div> | ||||
|  | ||||
|             {/* The calendar */} | ||||
|             <div style={{ flex: 5 }}> | ||||
|               <FullCalendar | ||||
|                 editable={true} | ||||
|                 selectable={true} | ||||
|                 plugins={[dayGridPlugin, listPlugin, interactionPlugin]} | ||||
|                 initialView="dayGridMonth" | ||||
|                 height="700px" | ||||
|                 locale={frLocale} | ||||
|                 headerToolbar={{ | ||||
|                   left: "prev,next today", | ||||
|                   center: "title", | ||||
|                   right: "dayGridMonth,dayGridWeek,dayGridDay,listWeek", | ||||
|                 }} | ||||
|                 select={onSelect} | ||||
|                 eventClick={onEventClick} | ||||
|                 events={visibleReservations?.map((r) => { | ||||
|                   const a = accommodations.accommodations.get( | ||||
|                     r.accommodation_id | ||||
|                   )!; | ||||
|                   const u = users?.find((u) => u.user_id === r.user_id); | ||||
|                   return { | ||||
|                     title: `${u?.user_name} - ${a.name}`, | ||||
|                     start: fmtUnixDateFullCalendar(r.reservation_start, false), | ||||
|                     end: fmtUnixDateFullCalendar(r.reservation_end, true), | ||||
|                     allDay: true, | ||||
|                     color: a.color ? "#" + a.color : undefined, | ||||
|                     borderColor: | ||||
|                       r.validated === true | ||||
|                         ? "green" | ||||
|                         : r.validated === false | ||||
|                         ? "red" | ||||
|                         : "grey ", | ||||
|                     extendedProps: { | ||||
|                       id: r.id, | ||||
|                     }, | ||||
|                   }; | ||||
|                 })} | ||||
|               /> | ||||
|             </div> | ||||
|  | ||||
|             {/* Calendar event popover */} | ||||
|             <div | ||||
|               ref={eventPopupAnchor} | ||||
|               id="active-event-anchor" | ||||
|               style={{ | ||||
|                 position: "fixed", | ||||
|                 top: activeEvent?.y + "px", | ||||
|                 left: activeEvent?.x + "px", | ||||
|                 width: activeEvent?.w + "px", | ||||
|                 height: activeEvent?.h + "px", | ||||
|                 backgroundColor: "pink", | ||||
|                 zIndex: 0, | ||||
|               }} | ||||
|             ></div> | ||||
|             <Popover | ||||
|               open={activeEvent !== undefined} | ||||
|               anchorEl={eventPopupAnchor.current} | ||||
|               onClose={() => { | ||||
|                 setActiveEvent(undefined); | ||||
|               }} | ||||
|               anchorOrigin={{ | ||||
|                 vertical: "bottom", | ||||
|                 horizontal: "left", | ||||
|               }} | ||||
|             > | ||||
|               <Card sx={{ maxWidth: 345 }} elevation={6}> | ||||
|                 <CardHeader | ||||
|                   avatar={ | ||||
|                     <Avatar sx={{ bgcolor: red[500] }}> | ||||
|                       {activeEvent?.user.user_name | ||||
|                         .substring(0, 1) | ||||
|                         .toLocaleUpperCase()} | ||||
|                     </Avatar> | ||||
|                   } | ||||
|                   title={activeEvent?.user.user_name} | ||||
|                   subheader={activeEvent?.user.user_mail} | ||||
|                 /> | ||||
|  | ||||
|                 <CardContent> | ||||
|                   <Typography variant="body2" color="text.secondary"> | ||||
|                     <p> | ||||
|                       Réservation de {activeEvent?.accommodation.name} | ||||
|                       <br /> | ||||
|                       <em>{activeEvent?.accommodation.description}</em> | ||||
|                     </p> | ||||
|                     <p> | ||||
|                       Du{" "} | ||||
|                       {fmtUnixDate( | ||||
|                         activeEvent?.reservation.reservation_start ?? 0 | ||||
|                       )}{" "} | ||||
|                       <br /> | ||||
|                       Au{" "} | ||||
|                       {fmtUnixDate( | ||||
|                         activeEvent?.reservation.reservation_end ?? 0 | ||||
|                       )} | ||||
|                     </p> | ||||
|                     <p> | ||||
|                       <strong> | ||||
|                         {activeEvent?.reservation.validated === false ? ( | ||||
|                           <span style={{ color: "#f44336" }}>Refusée</span> | ||||
|                         ) : activeEvent?.reservation.validated === true ? ( | ||||
|                           <span style={{ color: "#66bb6a" }}>Validée</span> | ||||
|                         ) : ( | ||||
|                           <span style={{ color: "#29b6f6" }}> | ||||
|                             En attente de validation | ||||
|                           </span> | ||||
|                         )} | ||||
|                       </strong> | ||||
|                     </p> | ||||
|                   </Typography> | ||||
|                 </CardContent> | ||||
|                 <CardActions disableSpacing> | ||||
|                   {activeEvent?.accommodation.need_validation && | ||||
|                     family.family.is_admin && ( | ||||
|                       <> | ||||
|                         <Tooltip | ||||
|                           title="Valider (ou rejeter) la réservation" | ||||
|                           arrow | ||||
|                         > | ||||
|                           <IconButton | ||||
|                             onClick={(e) => | ||||
|                               setValidateResaAnchorEl(e.currentTarget) | ||||
|                             } | ||||
|                           > | ||||
|                             <RuleIcon /> | ||||
|                           </IconButton> | ||||
|                         </Tooltip> | ||||
|                         <Menu | ||||
|                           anchorEl={validateResaAnchorEl} | ||||
|                           open={!!validateResaAnchorEl && !!activeEvent} | ||||
|                           onClose={() => setValidateResaAnchorEl(null)} | ||||
|                         > | ||||
|                           <MenuItem | ||||
|                             disabled={ | ||||
|                               activeEvent.reservation.validated === true | ||||
|                             } | ||||
|                             onClick={() => | ||||
|                               validateReservation(activeEvent.reservation) | ||||
|                             } | ||||
|                           > | ||||
|                             Valider | ||||
|                           </MenuItem> | ||||
|                           <MenuItem | ||||
|                             disabled={ | ||||
|                               activeEvent.reservation.validated === false | ||||
|                             } | ||||
|                             onClick={() => | ||||
|                               rejectReservation(activeEvent.reservation) | ||||
|                             } | ||||
|                           > | ||||
|                             Rejeter | ||||
|                           </MenuItem> | ||||
|                         </Menu> | ||||
|                       </> | ||||
|                     )} | ||||
|  | ||||
|                   {user.user.id === activeEvent?.reservation.user_id && ( | ||||
|                     <> | ||||
|                       <Tooltip title="Modifier les dates de réservation" arrow> | ||||
|                         <IconButton | ||||
|                           disabled={ | ||||
|                             !activeEvent.accommodation.open_to_reservations | ||||
|                           } | ||||
|                           onClick={() => | ||||
|                             changeReservation(activeEvent?.reservation) | ||||
|                           } | ||||
|                         > | ||||
|                           <EditIcon /> | ||||
|                         </IconButton> | ||||
|                       </Tooltip> | ||||
|                       <Tooltip title="Supprimer la réservation" arrow> | ||||
|                         <IconButton | ||||
|                           color="error" | ||||
|                           onClick={() => | ||||
|                             deleteReservation(activeEvent?.reservation) | ||||
|                           } | ||||
|                         > | ||||
|                           <DeleteIcon /> | ||||
|                         </IconButton> | ||||
|                       </Tooltip> | ||||
|                     </> | ||||
|                   )} | ||||
|                 </CardActions> | ||||
|               </Card> | ||||
|             </Popover> | ||||
|           </div> | ||||
|         )} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -0,0 +1,410 @@ | ||||
| import AddIcon from "@mui/icons-material/Add"; | ||||
| import CheckIcon from "@mui/icons-material/Check"; | ||||
| import CloseIcon from "@mui/icons-material/Close"; | ||||
| import HouseIcon from "@mui/icons-material/House"; | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardActions, | ||||
|   CardContent, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { | ||||
|   Accommodation, | ||||
|   AccommodationListApi, | ||||
| } from "../../../api/accommodations/AccommodationListApi"; | ||||
| import { | ||||
|   AccommodationCalendarURL, | ||||
|   AccommodationsCalendarURLApi, | ||||
| } from "../../../api/accommodations/AccommodationsCalendarURLApi"; | ||||
| import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider"; | ||||
| import { useLoadingMessage } from "../../../hooks/context_providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../../../hooks/context_providers/SnackbarProvider"; | ||||
| import { useCreateAccommodationCalendarURL } from "../../../hooks/context_providers/accommodations/CreateAccommodationCalendarURLDialogProvider"; | ||||
| import { useInstallCalendarDialog } from "../../../hooks/context_providers/accommodations/InstallCalendarDialogProvider"; | ||||
| import { useUpdateAccommodation } from "../../../hooks/context_providers/accommodations/UpdateAccommodationDialogProvider"; | ||||
| import { AsyncWidget } from "../../../widgets/AsyncWidget"; | ||||
| import { useFamily } from "../../../widgets/BaseFamilyRoute"; | ||||
| import { FamilyCard } from "../../../widgets/FamilyCard"; | ||||
| import { TimeWidget } from "../../../widgets/TimeWidget"; | ||||
| import { useAccommodations } from "../../../widgets/accommodations/BaseAccommodationsRoute"; | ||||
|  | ||||
| const CARDS_WIDTH = "500px"; | ||||
|  | ||||
| export function AccommodationsSettingsRoute(): React.ReactElement { | ||||
|   return ( | ||||
|     <> | ||||
|       <AccommodationsListCard /> | ||||
|       <AccommodationsCalURLsCard /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function AccommodationsListCard(): React.ReactElement { | ||||
|   const loading = useLoadingMessage(); | ||||
|   const confirm = useConfirm(); | ||||
|   const snackbar = useSnackbar(); | ||||
|  | ||||
|   const family = useFamily(); | ||||
|   const accommodations = useAccommodations(); | ||||
|  | ||||
|   const [error, setError] = React.useState<string>(); | ||||
|   const [success, setSuccess] = React.useState<string>(); | ||||
|  | ||||
|   const updateAccommodation = useUpdateAccommodation(); | ||||
|  | ||||
|   const createAccommodation = async () => { | ||||
|     setError(undefined); | ||||
|     setSuccess(undefined); | ||||
|     try { | ||||
|       const accommodation = await updateAccommodation( | ||||
|         { | ||||
|           name: "", | ||||
|           open_to_reservations: true, | ||||
|           need_validation: false, | ||||
|           color: "2196f3", | ||||
|         }, | ||||
|         true | ||||
|       ); | ||||
|  | ||||
|       if (!accommodation) return; | ||||
|  | ||||
|       loading.show("Création du logement en cours..."); | ||||
|  | ||||
|       await AccommodationListApi.Create(family.family, accommodation); | ||||
|  | ||||
|       snackbar("Le logement a été créé avec succès !"); | ||||
|       await accommodations.reloadAccommodationsList(); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to create accommodation!", e); | ||||
|       setError(`Échec de la création du logement! ${e}`); | ||||
|     } finally { | ||||
|       loading.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const requestUpdateAccommodation = async (a: Accommodation) => { | ||||
|     setError(undefined); | ||||
|     setSuccess(undefined); | ||||
|     try { | ||||
|       const update = await updateAccommodation(a, false); | ||||
|       if (!update) return; | ||||
|  | ||||
|       loading.show("Mise à jour du logement en cours..."); | ||||
|  | ||||
|       await AccommodationListApi.Update(a, update); | ||||
|  | ||||
|       snackbar("Le logement a été créé avec succès !"); | ||||
|       await accommodations.reloadAccommodationsList(); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to update accommodation!", e); | ||||
|       setError(`Échec de la mise à jour du logement! ${e}`); | ||||
|     } finally { | ||||
|       loading.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteAccommodation = async (a: Accommodation) => { | ||||
|     setError(undefined); | ||||
|     setSuccess(undefined); | ||||
|     try { | ||||
|       if ( | ||||
|         !(await confirm( | ||||
|           `Voulez-vous vraiment supprimer le logement '${a.name}' ? Cette opération est définitive !` | ||||
|         )) | ||||
|       ) | ||||
|         return; | ||||
|       loading.show("Suppression du logement en cours..."); | ||||
|  | ||||
|       await AccommodationListApi.Delete(a); | ||||
|  | ||||
|       snackbar("Le logement a été supprimé avec succès !"); | ||||
|       await accommodations.reloadAccommodationsList(); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to delete accommodation!", e); | ||||
|       setError(`Échec de la suppression du logement! ${e}`); | ||||
|     } finally { | ||||
|       loading.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <FamilyCard error={error} success={success} style={{ width: CARDS_WIDTH }}> | ||||
|       <CardContent> | ||||
|         <Typography gutterBottom variant="h5" component="div"> | ||||
|           Logements | ||||
|         </Typography> | ||||
|  | ||||
|         {/* Display the list of accommodations */} | ||||
|         {accommodations.accommodations.isEmpty && ( | ||||
|           <div style={{ textAlign: "center", margin: "25px" }}> | ||||
|             Aucun logement enregistré pour le moment ! | ||||
|           </div> | ||||
|         )} | ||||
|         {accommodations.accommodations.fullList.map((a) => ( | ||||
|           <AccommodationCard | ||||
|             accommodation={a} | ||||
|             onRequestUpdate={requestUpdateAccommodation} | ||||
|             onRequestDelete={deleteAccommodation} | ||||
|           /> | ||||
|         ))} | ||||
|  | ||||
|         {family.family.is_admin && ( | ||||
|           <Button | ||||
|             startIcon={<AddIcon />} | ||||
|             variant="outlined" | ||||
|             color="info" | ||||
|             fullWidth | ||||
|             onClick={createAccommodation} | ||||
|             size={"large"} | ||||
|           > | ||||
|             Ajouter un logement | ||||
|           </Button> | ||||
|         )} | ||||
|       </CardContent> | ||||
|     </FamilyCard> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function AccommodationCard(p: { | ||||
|   accommodation: Accommodation; | ||||
|   onRequestUpdate: (a: Accommodation) => void; | ||||
|   onRequestDelete: (a: Accommodation) => void; | ||||
| }): React.ReactElement { | ||||
|   const family = useFamily(); | ||||
|   return ( | ||||
|     <Card sx={{ minWidth: 275, margin: "10px 0px" }} variant="outlined"> | ||||
|       <CardContent> | ||||
|         <Typography sx={{ fontSize: 14 }} color="text.secondary" gutterBottom> | ||||
|           Mis à jour il y a <TimeWidget time={p.accommodation.time_update} /> | ||||
|         </Typography> | ||||
|         <Typography variant="h5" component="div"> | ||||
|           <HouseIcon sx={{ color: "#" + p.accommodation.color }} />{" "} | ||||
|           {p.accommodation.name} | ||||
|         </Typography> | ||||
|         <Typography sx={{ mb: 1.5 }} color="text.secondary"> | ||||
|           {p.accommodation.description} | ||||
|         </Typography> | ||||
|         <Typography variant="body2"> | ||||
|           <BoolIcon checked={p.accommodation.open_to_reservations} /> Ouvert aux | ||||
|           réservations | ||||
|           <br /> | ||||
|           <BoolIcon checked={!p.accommodation.need_validation} /> Réservation | ||||
|           sans validation d'un administrateur | ||||
|         </Typography> | ||||
|       </CardContent> | ||||
|       {family.family.is_admin && ( | ||||
|         <CardActions> | ||||
|           <span style={{ flex: 1 }}></span> | ||||
|           <Button | ||||
|             size="small" | ||||
|             onClick={() => p.onRequestUpdate(p.accommodation)} | ||||
|           > | ||||
|             Modifier | ||||
|           </Button> | ||||
|           <Button | ||||
|             size="small" | ||||
|             color="error" | ||||
|             onClick={() => p.onRequestDelete(p.accommodation)} | ||||
|           > | ||||
|             Supprimer | ||||
|           </Button> | ||||
|         </CardActions> | ||||
|       )} | ||||
|     </Card> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function BoolIcon(p: { checked?: boolean }): React.ReactElement { | ||||
|   return p.checked ? ( | ||||
|     <CheckIcon color="success" /> | ||||
|   ) : ( | ||||
|     <CloseIcon color="error" /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function AccommodationsCalURLsCard(): React.ReactElement { | ||||
|   const key = React.useRef(0); | ||||
|  | ||||
|   const confirm = useConfirm(); | ||||
|   const loading = useLoadingMessage(); | ||||
|  | ||||
|   const [error, setError] = React.useState<string>(); | ||||
|   const [success, setSuccess] = React.useState<string>(); | ||||
|  | ||||
|   const [list, setList] = React.useState< | ||||
|     AccommodationCalendarURL[] | undefined | ||||
|   >(); | ||||
|  | ||||
|   const family = useFamily(); | ||||
|  | ||||
|   const createCalendarURLDialog = useCreateAccommodationCalendarURL(); | ||||
|   const calendarURLDialog = useInstallCalendarDialog(); | ||||
|  | ||||
|   const load = async () => { | ||||
|     setList(await AccommodationsCalendarURLApi.GetList(family.family)); | ||||
|   }; | ||||
|  | ||||
|   const reload = () => { | ||||
|     key.current += 1; | ||||
|     setList(undefined); | ||||
|   }; | ||||
|  | ||||
|   const onRequestDelete = async (c: AccommodationCalendarURL) => { | ||||
|     setError(undefined); | ||||
|     setSuccess(undefined); | ||||
|     try { | ||||
|       if ( | ||||
|         !(await confirm( | ||||
|           `Voulez-vous vraiment supprimer le calendrier '${c.name}' ? Cette opération est définitive !` | ||||
|         )) | ||||
|       ) | ||||
|         return; | ||||
|  | ||||
|       loading.show("Suppression du calendrier en cours..."); | ||||
|  | ||||
|       await AccommodationsCalendarURLApi.Delete(c); | ||||
|  | ||||
|       setSuccess("Le calendrier a été supprimé avec succès !"); | ||||
|       reload(); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to delete accommodation!", e); | ||||
|       setError(`Échec de la suppression du logement! ${e}`); | ||||
|     } finally { | ||||
|       loading.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const createCalendarURL = async () => { | ||||
|     try { | ||||
|       const newCal = await createCalendarURLDialog(); | ||||
|  | ||||
|       if (!newCal) return; | ||||
|  | ||||
|       loading.show("Création du calendrier en cours..."); | ||||
|  | ||||
|       const cal = await AccommodationsCalendarURLApi.Create( | ||||
|         family.family, | ||||
|         newCal | ||||
|       ); | ||||
|  | ||||
|       setSuccess("Le calendrier a été créé avec succès !"); | ||||
|  | ||||
|       reload(); | ||||
|  | ||||
|       calendarURLDialog(cal); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to create new accommodation calendar URL!", e); | ||||
|       setError(`Échec de la création du calendrier! ${e}`); | ||||
|     } finally { | ||||
|       loading.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <FamilyCard error={error} success={success} style={{ width: CARDS_WIDTH }}> | ||||
|       <CardContent> | ||||
|         <Typography gutterBottom variant="h5" component="div"> | ||||
|           URL de calendriers | ||||
|         </Typography> | ||||
|         <Typography> | ||||
|           Vous pouvez, si vous le souhaitez, importer dans votre application de | ||||
|           calendrier le planning de réservation des logements. Pour ce faire, il | ||||
|           vous suffit de créer une URL de calendrier. | ||||
|         </Typography> | ||||
|  | ||||
|         <Alert severity="info"> | ||||
|           Les calendriers créés ici ne sont visible que par vous. Vous ne pouvez | ||||
|           pas manipuler les calendriers créés par les autres membres de la | ||||
|           famille. | ||||
|         </Alert> | ||||
|  | ||||
|         <Button | ||||
|           startIcon={<AddIcon />} | ||||
|           variant="outlined" | ||||
|           color="info" | ||||
|           fullWidth | ||||
|           onClick={createCalendarURL} | ||||
|           size={"large"} | ||||
|         > | ||||
|           Créer un calendrier | ||||
|         </Button> | ||||
|  | ||||
|         <br /> | ||||
|         <br /> | ||||
|  | ||||
|         <AsyncWidget | ||||
|           ready={list !== undefined} | ||||
|           loadKey={key.current} | ||||
|           load={load} | ||||
|           errMsg="Echec du chargement de la liste des calendriers !" | ||||
|           build={() => | ||||
|             list?.length === 0 ? ( | ||||
|               <> | ||||
|                 <p style={{ textAlign: "center" }}> | ||||
|                   Vous n'avez créé aucun calendrier pour le moment ! | ||||
|                 </p> | ||||
|               </> | ||||
|             ) : ( | ||||
|               <> | ||||
|                 {list?.map((c) => ( | ||||
|                   <CalendarItem c={c} onRequestDelete={onRequestDelete} /> | ||||
|                 ))} | ||||
|               </> | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|       </CardContent> | ||||
|     </FamilyCard> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function CalendarItem(p: { | ||||
|   c: AccommodationCalendarURL; | ||||
|   onRequestDelete: (c: AccommodationCalendarURL) => void; | ||||
| }): React.ReactElement { | ||||
|   const accommodations = useAccommodations(); | ||||
|  | ||||
|   const installCal = useInstallCalendarDialog(); | ||||
|  | ||||
|   return ( | ||||
|     <Card sx={{ minWidth: 275, margin: "10px 0px" }} variant="outlined"> | ||||
|       <CardContent> | ||||
|         <Typography | ||||
|           sx={{ fontSize: 14 }} | ||||
|           color="text.secondary" | ||||
|           gutterBottom | ||||
|         ></Typography> | ||||
|         <Typography variant="h5" component="div"> | ||||
|           {p.c.name} | ||||
|         </Typography> | ||||
|         <Typography sx={{ mb: 1.5 }} color="text.secondary"> | ||||
|           {p.c.accommodation_id | ||||
|             ? accommodations.accommodations.get(p.c.accommodation_id)?.name | ||||
|             : "Tous les logements"} | ||||
|         </Typography> | ||||
|         <Typography variant="body2"> | ||||
|           Créé il y a <TimeWidget time={p.c.time_create} /> | ||||
|           <br /> | ||||
|           Utilisé il y a <TimeWidget time={p.c.time_used} /> | ||||
|         </Typography> | ||||
|       </CardContent> | ||||
|  | ||||
|       <CardActions> | ||||
|         <span style={{ flex: 1 }}></span> | ||||
|         <Button size="small" onClick={() => installCal(p.c)}> | ||||
|           Installer | ||||
|         </Button> | ||||
|         <Button | ||||
|           size="small" | ||||
|           color="error" | ||||
|           onClick={() => p.onRequestDelete(p.c)} | ||||
|         > | ||||
|           Supprimer | ||||
|         </Button> | ||||
|       </CardActions> | ||||
|     </Card> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										21
									
								
								geneit_app/src/utils/form_utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								geneit_app/src/utils/form_utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import { LenConstraint } from "../api/ServerApi"; | ||||
|  | ||||
| /** | ||||
|  * Check if a constraint was respected or not | ||||
|  * | ||||
|  * @returns An error message appropriate for the constraint | ||||
|  * violation, if any, or undefined otherwise | ||||
|  */ | ||||
| export function checkConstraint( | ||||
|   constraint: LenConstraint, | ||||
|   value: string | undefined | ||||
| ): string | undefined { | ||||
|   value = value ?? ""; | ||||
|   if (value.length < constraint.min) | ||||
|     return `Veuillez indiquer au moins ${constraint.min} caractères !`; | ||||
|  | ||||
|   if (value.length > constraint.max) | ||||
|     return `Veuillez indiquer au maximum ${constraint.max} caractères !`; | ||||
|  | ||||
|   return undefined; | ||||
| } | ||||
							
								
								
									
										31
									
								
								geneit_app/src/utils/time_utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								geneit_app/src/utils/time_utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| /** | ||||
|  * Get formatted UNIX date | ||||
|  */ | ||||
| export function fmtUnixDate(time: number): string { | ||||
|   return new Date(time * 1000).toLocaleString("fr-FR"); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get formatted UNIX date for Full Calendar | ||||
|  */ | ||||
| export function fmtUnixDateFullCalendar( | ||||
|   time: number, | ||||
|   correctEnd: boolean | ||||
| ): string { | ||||
|   let d = new Date(time * 1000); | ||||
|  | ||||
|   if (d.getHours() > 0 && correctEnd) | ||||
|     d = new Date(time * 1000 + 3600 * 24 * 1000); | ||||
|  | ||||
|   const s = `${d.getFullYear()}-${(d.getMonth() + 1) | ||||
|     .toString(10) | ||||
|     .padStart(2, "0")}-${d.getDate().toString(10).padStart(2, "0")}`; /*T${d | ||||
|     .getHours() | ||||
|     .toString(10) | ||||
|     .padStart(2, "0")}:${d.getMinutes().toString(10).padStart(2, "0")}:${d | ||||
|     .getSeconds() | ||||
|     .toString(10) | ||||
|     .padStart(2, "0")}`*/ | ||||
|  | ||||
|   return s; | ||||
| } | ||||
| @@ -5,12 +5,14 @@ import { | ||||
|   mdiCrowd, | ||||
|   mdiFamilyTree, | ||||
|   mdiFileTree, | ||||
|   mdiHomeGroup, | ||||
|   mdiHumanMaleFemale, | ||||
|   mdiLockCheck, | ||||
|   mdiPlus, | ||||
|   mdiRefresh, | ||||
| } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; | ||||
| import HomeIcon from "@mui/icons-material/Home"; | ||||
| import { | ||||
|   Box, | ||||
| @@ -184,6 +186,24 @@ export function BaseFamilyRoute(): React.ReactElement { | ||||
|                   </> | ||||
|                 )} | ||||
|  | ||||
|                 {family?.enable_accommodations && ( | ||||
|                   <> | ||||
|                     <Divider sx={{ my: 1 }} /> | ||||
|                     <ListSubheader component="div">Logements</ListSubheader> | ||||
|  | ||||
|                     <FamilyLink | ||||
|                       icon={<HomeIcon />} | ||||
|                       label="Accueil" | ||||
|                       uri="accommodations" | ||||
|                     /> | ||||
|                     <FamilyLink | ||||
|                       icon={<CalendarMonthIcon />} | ||||
|                       label="Réservations" | ||||
|                       uri="accommodations/reservations" | ||||
|                     /> | ||||
|                   </> | ||||
|                 )} | ||||
|  | ||||
|                 <Divider sx={{ my: 1 }} /> | ||||
|                 <ListSubheader component="div">Administration</ListSubheader> | ||||
|  | ||||
| @@ -207,6 +227,14 @@ export function BaseFamilyRoute(): React.ReactElement { | ||||
|                   /> | ||||
|                 )} | ||||
|  | ||||
|                 {family?.enable_accommodations && ( | ||||
|                   <FamilyLink | ||||
|                     icon={<Icon path={mdiHomeGroup} size={1} />} | ||||
|                     label="Logements" | ||||
|                     uri="accommodations/settings" | ||||
|                   /> | ||||
|                 )} | ||||
|  | ||||
|                 {/* Invitation code */} | ||||
|  | ||||
|                 <ListItem | ||||
|   | ||||
							
								
								
									
										30
									
								
								geneit_app/src/widgets/CopyToClipboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								geneit_app/src/widgets/CopyToClipboard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import { ButtonBase } from "@mui/material"; | ||||
| import { PropsWithChildren } from "react"; | ||||
| import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; | ||||
|  | ||||
| export function CopyToClipboard( | ||||
|   p: PropsWithChildren<{ content: string }> | ||||
| ): React.ReactElement { | ||||
|   const snackbar = useSnackbar(); | ||||
|  | ||||
|   const copy = () => { | ||||
|     navigator.clipboard.writeText(p.content); | ||||
|     snackbar(`${p.content} a été copié dans le presse papier.`); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <ButtonBase | ||||
|       onClick={copy} | ||||
|       style={{ | ||||
|         display: "inline-block", | ||||
|         alignItems: "unset", | ||||
|         textAlign: "unset", | ||||
|         position: "relative", | ||||
|         padding: "0px", | ||||
|       }} | ||||
|       disableRipple | ||||
|     > | ||||
|       {p.children} | ||||
|     </ButtonBase> | ||||
|   ); | ||||
| } | ||||
| @@ -2,10 +2,14 @@ import { Alert, Card } from "@mui/material"; | ||||
| import { PropsWithChildren } from "react"; | ||||
|  | ||||
| export function FamilyCard( | ||||
|   p: PropsWithChildren<{ error?: string; success?: string }> | ||||
|   p: PropsWithChildren<{ | ||||
|     error?: string; | ||||
|     success?: string; | ||||
|     style?: React.CSSProperties | undefined; | ||||
|   }> | ||||
| ): React.ReactElement { | ||||
|   return ( | ||||
|     <Card style={{ margin: "10px auto", maxWidth: "450px" }}> | ||||
|     <Card style={{ ...p.style, margin: "10px auto", maxWidth: "450px" }}> | ||||
|       {p.error && <Alert severity="error">{p.error}</Alert>} | ||||
|       {p.success && <Alert severity="success">{p.success}</Alert>} | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,84 @@ | ||||
| import React from "react"; | ||||
| import { Outlet } from "react-router-dom"; | ||||
| import { | ||||
|   AccommodationListApi, | ||||
|   AccommodationsList, | ||||
| } from "../../api/accommodations/AccommodationListApi"; | ||||
| import { CreateAccommodationCalendarURLDialogProvider } from "../../hooks/context_providers/accommodations/CreateAccommodationCalendarURLDialogProvider"; | ||||
| import { InstallCalendarDialogProvider } from "../../hooks/context_providers/accommodations/InstallCalendarDialogProvider"; | ||||
| import { UpdateAccommodationDialogProvider } from "../../hooks/context_providers/accommodations/UpdateAccommodationDialogProvider"; | ||||
| import { UpdateReservationDialogProvider } from "../../hooks/context_providers/accommodations/UpdateReservationDialogProvider"; | ||||
| import { AsyncWidget } from "../AsyncWidget"; | ||||
| import { useFamily } from "../BaseFamilyRoute"; | ||||
|  | ||||
| interface AccommodationsContext { | ||||
|   accommodations: AccommodationsList; | ||||
|   reloadAccommodationsList: () => Promise<void>; | ||||
| } | ||||
|  | ||||
| const AccommodationsContextK = | ||||
|   React.createContext<AccommodationsContext | null>(null); | ||||
|  | ||||
| export function BaseAccommodationsRoute(): React.ReactElement { | ||||
|   const family = useFamily(); | ||||
|  | ||||
|   const [accommodations, setAccommodations] = | ||||
|     React.useState<null | AccommodationsList>(null); | ||||
|  | ||||
|   const loadKey = React.useRef(1); | ||||
|  | ||||
|   const loadPromise = React.useRef<() => void>(); | ||||
|  | ||||
|   const load = async () => { | ||||
|     setAccommodations( | ||||
|       await AccommodationListApi.GetListOfFamily(family.family) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const onReload = async () => { | ||||
|     loadKey.current += 1; | ||||
|     setAccommodations(null); | ||||
|  | ||||
|     return new Promise<void>((res, _rej) => { | ||||
|       loadPromise.current = () => res(); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <AsyncWidget | ||||
|       ready={accommodations !== null} | ||||
|       loadKey={`${family.familyId}-${loadKey.current}`} | ||||
|       load={load} | ||||
|       errMsg="Échec du chargement des informations sur les logements de la famille !" | ||||
|       build={() => { | ||||
|         if (loadPromise.current != null) { | ||||
|           loadPromise.current?.(); | ||||
|           loadPromise.current = undefined; | ||||
|         } | ||||
|  | ||||
|         return ( | ||||
|           <AccommodationsContextK.Provider | ||||
|             value={{ | ||||
|               accommodations: accommodations!, | ||||
|               reloadAccommodationsList: onReload, | ||||
|             }} | ||||
|           > | ||||
|             <UpdateAccommodationDialogProvider> | ||||
|               <CreateAccommodationCalendarURLDialogProvider> | ||||
|                 <InstallCalendarDialogProvider> | ||||
|                   <UpdateReservationDialogProvider> | ||||
|                     <Outlet /> | ||||
|                   </UpdateReservationDialogProvider> | ||||
|                 </InstallCalendarDialogProvider> | ||||
|               </CreateAccommodationCalendarURLDialogProvider> | ||||
|             </UpdateAccommodationDialogProvider> | ||||
|           </AccommodationsContextK.Provider> | ||||
|         ); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useAccommodations(): AccommodationsContext { | ||||
|   return React.useContext(AccommodationsContextK)!; | ||||
| } | ||||
| @@ -5,16 +5,20 @@ export function PropCheckbox(p: { | ||||
|   label: string; | ||||
|   checked: boolean | undefined; | ||||
|   onValueChange: (v: boolean) => void; | ||||
|   checkboxAlwaysVisible?: boolean; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && p.checked) | ||||
|     return <Typography variant="body2">{p.label}</Typography>; | ||||
|   if (!p.checkboxAlwaysVisible) { | ||||
|     if (!p.editable && p.checked) | ||||
|       return <Typography variant="body2">{p.label}</Typography>; | ||||
|  | ||||
|   if (!p.editable) return <></>; | ||||
|     if (!p.editable) return <></>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <FormControlLabel | ||||
|       control={ | ||||
|         <Checkbox | ||||
|           disabled={!p.editable} | ||||
|           checked={p.checked} | ||||
|           onChange={(e) => p.onValueChange(e.target.checked)} | ||||
|         /> | ||||
|   | ||||
							
								
								
									
										24
									
								
								geneit_app/src/widgets/forms/PropColorPicker.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								geneit_app/src/widgets/forms/PropColorPicker.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { MuiColorInput } from "mui-color-input"; | ||||
| import { PropEdit } from "./PropEdit"; | ||||
|  | ||||
| export function PropColorPicker(p: { | ||||
|   editable: boolean; | ||||
|   label: string; | ||||
|   value?: string; | ||||
|   onChange: (v: string | undefined) => void; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable) { | ||||
|     if (!p.value) return <></>; | ||||
|  | ||||
|     return <PropEdit editable={false} label={p.label} value={`#${p.value}`} />; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <MuiColorInput | ||||
|       value={"#" + (p.value ?? "")} | ||||
|       fallbackValue="#ffffff" | ||||
|       format="hex" | ||||
|       onChange={(_v, c) => p.onChange(c.hex.substring(1))} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										103
									
								
								geneit_app/src/widgets/forms/PropDateInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								geneit_app/src/widgets/forms/PropDateInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | ||||
| import dayjs from "dayjs"; | ||||
| import "dayjs/locale/fr"; | ||||
| import { fmtUnixDate } from "../../utils/time_utils"; | ||||
| import { PropEdit } from "./PropEdit"; | ||||
| import { Checkbox, FormControlLabel } from "@mui/material"; | ||||
|  | ||||
| export function PropDateInput(p: { | ||||
|   editable: boolean; | ||||
|   label: string; | ||||
|   value: number | undefined; | ||||
|   onChange: (v: number | undefined) => void; | ||||
|   lastSecOfDay?: boolean; | ||||
|   minDate?: number; | ||||
|   maxDate?: number; | ||||
|   canSetMiddleDay?: boolean; | ||||
| }): React.ReactElement { | ||||
|   // Check for mid-day value | ||||
|   let isMidDay = false; | ||||
|   if (p.value) { | ||||
|     const d = new Date(p.value * 1000); | ||||
|     isMidDay = | ||||
|       d.getHours() === 12 && d.getMinutes() === 0 && d.getSeconds() === 0; | ||||
|   } | ||||
|  | ||||
|   // Shift value | ||||
|   let shiftV = p.value; | ||||
|   if (shiftV && p.lastSecOfDay) { | ||||
|     const d = new Date(shiftV * 1000); | ||||
|     if (d.getHours() === 0) { | ||||
|       shiftV -= 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (!p.editable) { | ||||
|     if (!shiftV) return <></>; | ||||
|  | ||||
|     return ( | ||||
|       <PropEdit editable={false} label={p.label} value={fmtUnixDate(shiftV)} /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const value = dayjs( | ||||
|     shiftV && p.value! > 0 ? new Date(shiftV * 1000) : undefined | ||||
|   ); | ||||
|  | ||||
|   const minDate = p.minDate ? dayjs(new Date(p.minDate * 1000)) : undefined; | ||||
|   const maxDate = p.maxDate ? dayjs(new Date(p.maxDate * 1000)) : undefined; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div style={{ height: "10px" }}></div> | ||||
|       <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="fr"> | ||||
|         <DatePicker | ||||
|           label={p.label} | ||||
|           value={value} | ||||
|           onChange={(v) => { | ||||
|             if (v && p.lastSecOfDay) { | ||||
|               v = v.set("hours", 23); | ||||
|               v = v.set("minutes", 59); | ||||
|               v = v.set("seconds", 59); | ||||
|             } | ||||
|             p.onChange?.(v ? v.unix() : undefined); | ||||
|           }} | ||||
|           minDate={minDate} | ||||
|           maxDate={maxDate} | ||||
|         /> | ||||
|       </LocalizationProvider> | ||||
|       {p.canSetMiddleDay && ( | ||||
|         <FormControlLabel | ||||
|           disabled={!p.value} | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={isMidDay} | ||||
|               onChange={(_ev, midDay) => { | ||||
|                 let v = value; | ||||
|                 if (midDay) { | ||||
|                   v = v.set("hours", 12); | ||||
|                   v = v.set("minutes", 0); | ||||
|                   v = v.set("seconds", 0); | ||||
|                 } else if (p.lastSecOfDay) { | ||||
|                   v = v.set("hours", 23); | ||||
|                   v = v.set("minutes", 59); | ||||
|                   v = v.set("seconds", 59); | ||||
|                 } else { | ||||
|                   v = v.set("hours", 0); | ||||
|                   v = v.set("minutes", 0); | ||||
|                   v = v.set("seconds", 0); | ||||
|                 } | ||||
|  | ||||
|                 p.onChange(v.unix()); | ||||
|               }} | ||||
|             /> | ||||
|           } | ||||
|           label="Mi-journée" | ||||
|         /> | ||||
|       )} | ||||
|       <div style={{ height: "30px" }}></div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -14,6 +14,7 @@ export function PropEdit(p: { | ||||
|   multiline?: boolean; | ||||
|   minRows?: number; | ||||
|   maxRows?: number; | ||||
|   helperText?: string; | ||||
| }): React.ReactElement { | ||||
|   if (((!p.editable && p.value) ?? "") === "") return <></>; | ||||
|  | ||||
| @@ -44,6 +45,7 @@ export function PropEdit(p: { | ||||
|           !p.checkValue(p.value)) || | ||||
|         false | ||||
|       } | ||||
|       helperText={p.helperText} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; | ||||
| import { PropEdit } from "./PropEdit"; | ||||
|  | ||||
| export interface SelectOption { | ||||
|   value: string; | ||||
|   value: string | undefined; | ||||
|   label: string; | ||||
| } | ||||
|  | ||||
| @@ -19,6 +19,7 @@ export function PropSelect(p: { | ||||
|     const value = p.options.find((o) => o.value === p.value)?.label; | ||||
|     return <PropEdit label={p.label} editable={p.editing} value={value} />; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <FormControl fullWidth variant="filled" style={{ marginBottom: "15px" }}> | ||||
|       <InputLabel>{p.label}</InputLabel> | ||||
|   | ||||
| @@ -5,14 +5,14 @@ import { MemberApi, MembersList } from "../../api/genealogy/MemberApi"; | ||||
| import { AsyncWidget } from "../AsyncWidget"; | ||||
| import { useFamily } from "../BaseFamilyRoute"; | ||||
|  | ||||
| interface FamilyContext { | ||||
| interface GenealogyContext { | ||||
|   members: MembersList; | ||||
|   couples: CouplesList; | ||||
|   reloadMembersList: () => Promise<void>; | ||||
|   reloadCouplesList: () => Promise<void>; | ||||
| } | ||||
|  | ||||
| const GenealogyContextK = React.createContext<FamilyContext | null>(null); | ||||
| const GenealogyContextK = React.createContext<GenealogyContext | null>(null); | ||||
|  | ||||
| export function BaseGenealogyRoute(): React.ReactElement { | ||||
|   const family = useFamily(); | ||||
| @@ -68,6 +68,6 @@ export function BaseGenealogyRoute(): React.ReactElement { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useGenealogy(): FamilyContext { | ||||
| export function useGenealogy(): GenealogyContext { | ||||
|   return React.useContext(GenealogyContextK)!; | ||||
| } | ||||
|   | ||||
							
								
								
									
										37
									
								
								geneit_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										37
									
								
								geneit_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -711,8 +711,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" | ||||
| dependencies = [ | ||||
|  "android-tzdata", | ||||
|  "iana-time-zone", | ||||
|  "js-sys", | ||||
|  "num-traits", | ||||
|  "serde", | ||||
|  "wasm-bindgen", | ||||
|  "windows-targets 0.52.5", | ||||
| ] | ||||
|  | ||||
| @@ -1404,13 +1406,16 @@ dependencies = [ | ||||
|  "anyhow", | ||||
|  "base64 0.22.1", | ||||
|  "bcrypt", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "diesel", | ||||
|  "diesel_migrations", | ||||
|  "env_logger", | ||||
|  "futures-util", | ||||
|  "httpdate", | ||||
|  "ical", | ||||
|  "image", | ||||
|  "lazy-regex", | ||||
|  "lazy_static", | ||||
|  "lettre", | ||||
|  "light-openid", | ||||
| @@ -1774,6 +1779,15 @@ dependencies = [ | ||||
|  "cc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "ical" | ||||
| version = "0.11.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9b7cab7543a8b7729a19e2c04309f902861293dcdae6558dfbeb634454d279f6" | ||||
| dependencies = [ | ||||
|  "thiserror", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "ident_case" | ||||
| version = "1.0.1" | ||||
| @@ -1939,6 +1953,29 @@ version = "0.3.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" | ||||
|  | ||||
| [[package]] | ||||
| name = "lazy-regex" | ||||
| version = "3.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "5d12be4595afdf58bd19e4a9f4e24187da2a66700786ff660a418e9059937a4c" | ||||
| dependencies = [ | ||||
|  "lazy-regex-proc_macros", | ||||
|  "once_cell", | ||||
|  "regex", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "lazy-regex-proc_macros" | ||||
| version = "3.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "44bcd58e6c97a7fcbaffcdc95728b393b8d98933bfadad49ed4097845b57ef0b" | ||||
| dependencies = [ | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "regex", | ||||
|  "syn 2.0.63", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "lazy_static" | ||||
| version = "1.4.0" | ||||
|   | ||||
| @@ -10,6 +10,7 @@ log = "0.4.21" | ||||
| env_logger = "0.11.3" | ||||
| clap = { version = "4.5.4", features = ["derive", "env"] } | ||||
| lazy_static = "1.4.0" | ||||
| lazy-regex = "3.1.0" | ||||
| anyhow = "1.0.83" | ||||
| actix-web = "4.5.1" | ||||
| actix-cors = "0.7.0" | ||||
| @@ -38,3 +39,5 @@ zip = "2.0.0" | ||||
| mime_guess = "2.0.4" | ||||
| tempfile = "3.10.1" | ||||
| base64 = "0.22.0" | ||||
| ical = { version = "0.11.0", features = ["generator", "ical", "vcard"] } | ||||
| chrono = "0.4.38" | ||||
| @@ -0,0 +1,6 @@ | ||||
| ALTER TABLE public.families | ||||
|     DROP COLUMN enable_accommodations; | ||||
|  | ||||
| DROP TABLE IF EXISTS accommodations_reservations_cals_urls; | ||||
| DROP TABLE IF EXISTS accommodations_reservations; | ||||
| DROP TABLE IF EXISTS accommodations_list; | ||||
| @@ -0,0 +1,52 @@ | ||||
| -- Add column to toggle accommodations module | ||||
| ALTER TABLE public.families | ||||
|     ADD enable_accommodations boolean NOT NULL DEFAULT false; | ||||
| COMMENT | ||||
|     ON COLUMN public.families.enable_accommodations IS 'Specify whether accommodations feature is enabled for the family'; | ||||
|  | ||||
|  | ||||
| -- Create tables | ||||
| CREATE TABLE IF NOT EXISTS accommodations_list | ||||
| ( | ||||
|     id                   SERIAL PRIMARY KEY, | ||||
|     family_id            integer     NOT NULL REFERENCES families, | ||||
|     time_create          BIGINT      NOT NULL, | ||||
|     time_update          BIGINT      NOT NULL, | ||||
|     name                 VARCHAR(50) NOT NULL, | ||||
|     need_validation      BOOLEAN     NOT NULL DEFAULT true, | ||||
|     description          text        NULL, | ||||
|     color                VARCHAR(6)  NULL, | ||||
|     open_to_reservations BOOLEAN     NOT NULL DEFAULT false | ||||
| ); | ||||
|  | ||||
| COMMENT ON COLUMN accommodations_list.need_validation is 'true if family admin review is required for validation. False otherwise'; | ||||
| COMMENT ON COLUMN accommodations_list.open_to_reservations is 'true if reservations can be created / updated. False otherwise'; | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS accommodations_reservations | ||||
| ( | ||||
|     id                SERIAL PRIMARY KEY, | ||||
|     family_id         integer NOT NULL REFERENCES families ON DELETE CASCADE, | ||||
|     accommodation_id  integer NOT NULL REFERENCES accommodations_list ON DELETE CASCADE, | ||||
|     user_id           INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, | ||||
|     time_create       BIGINT  NOT NULL, | ||||
|     time_update       BIGINT  NOT NULL, | ||||
|     reservation_start BIGINT  NOT NULL, | ||||
|     reservation_end   BIGINT  NOT NULL, | ||||
|     validated         BOOLEAN NULL | ||||
| ); | ||||
|  | ||||
| COMMENT ON COLUMN accommodations_reservations.validated is 'null if not reviewed yet. true if reservation is accepted. false if reservation is rejected'; | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS accommodations_reservations_cals_urls | ||||
| ( | ||||
|     id               SERIAL PRIMARY KEY, | ||||
|     family_id        integer     NOT NULL REFERENCES families ON DELETE CASCADE, | ||||
|     accommodation_id integer     NULL REFERENCES accommodations_list ON DELETE CASCADE, | ||||
|     user_id          INTEGER     NOT NULL REFERENCES users ON DELETE CASCADE, | ||||
|     name             VARCHAR(50) NOT NULL, | ||||
|     token            VARCHAR(50) NOT NULL, | ||||
|     time_create      BIGINT      NOT NULL, | ||||
|     time_used        BIGINT      NOT NULL | ||||
| ); | ||||
|  | ||||
| COMMENT ON COLUMN accommodations_reservations_cals_urls.accommodation_id is 'null to get reservations of all accommodations. otherwise get the reservations of the specified accommodation only'; | ||||
| @@ -60,6 +60,10 @@ pub struct StaticConstraints { | ||||
|     pub member_country: SizeConstraint, | ||||
|     pub member_sex: SizeConstraint, | ||||
|     pub member_note: SizeConstraint, | ||||
|  | ||||
|     pub accommodation_name_len: SizeConstraint, | ||||
|     pub accommodation_description_len: SizeConstraint, | ||||
|     pub accommodation_calendar_name_len: SizeConstraint, | ||||
| } | ||||
|  | ||||
| impl Default for StaticConstraints { | ||||
| @@ -91,6 +95,10 @@ impl Default for StaticConstraints { | ||||
|             member_country: SizeConstraint::new(0, 2), | ||||
|             member_sex: SizeConstraint::new(0, 1), | ||||
|             member_note: SizeConstraint::new(0, 35000), | ||||
|  | ||||
|             accommodation_name_len: SizeConstraint::new(1, 50), | ||||
|             accommodation_description_len: SizeConstraint::new(0, 500), | ||||
|             accommodation_calendar_name_len: SizeConstraint::new(2, 50), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -134,3 +142,10 @@ pub const THUMB_WIDTH: u32 = 350; | ||||
|  | ||||
| /// Thumbnail height | ||||
| pub const THUMB_HEIGHT: u32 = 350; | ||||
|  | ||||
| /// Accommodations reservations calendars tokens len | ||||
| pub const ACCOMMODATIONS_RESERVATIONS_CALENDARS_TOKENS_LEN: usize = 50; | ||||
|  | ||||
| /// Minimum interval before calendar used time update | ||||
| pub const ACCOMMODATIONS_RESERVATIONS_CAL_URL_TIME_USED_UPDATE_MIN_INTERVAL: Duration = | ||||
|     Duration::from_secs(60); | ||||
|   | ||||
							
								
								
									
										115
									
								
								geneit_backend/src/controllers/accommodations_list_controller.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								geneit_backend/src/controllers/accommodations_list_controller.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| use crate::constants::StaticConstraints; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::extractors::accommodation_extractor::FamilyAndAccommodationInPath; | ||||
| use crate::extractors::family_extractor::{FamilyInPath, FamilyInPathWithAdminMembership}; | ||||
| use crate::models::Accommodation; | ||||
| use crate::services::accommodations_list_service; | ||||
| use actix_web::{web, HttpResponse}; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum AccommodationListControllerErr { | ||||
|     #[error("Invalid name length!")] | ||||
|     InvalidNameLength, | ||||
|     #[error("Invalid description length!")] | ||||
|     InvalidDescriptionLength, | ||||
|     #[error("Malformed color!")] | ||||
|     MalformedColor, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, Clone)] | ||||
| pub struct AccommodationRequest { | ||||
|     pub name: String, | ||||
|     pub need_validation: bool, | ||||
|     pub description: Option<String>, | ||||
|     pub color: Option<String>, | ||||
|     pub open_to_reservations: bool, | ||||
| } | ||||
|  | ||||
| impl AccommodationRequest { | ||||
|     pub async fn to_accommodation(self, accommodation: &mut Accommodation) -> anyhow::Result<()> { | ||||
|         let c = StaticConstraints::default(); | ||||
|  | ||||
|         if !c.accommodation_name_len.validate(&self.name) { | ||||
|             return Err(AccommodationListControllerErr::InvalidNameLength.into()); | ||||
|         } | ||||
|         accommodation.name = self.name; | ||||
|  | ||||
|         if let Some(d) = &self.description { | ||||
|             if !c.accommodation_description_len.validate(d) { | ||||
|                 return Err(AccommodationListControllerErr::InvalidDescriptionLength.into()); | ||||
|             } | ||||
|         } | ||||
|         accommodation.description.clone_from(&self.description); | ||||
|  | ||||
|         if let Some(c) = &self.color { | ||||
|             if !lazy_regex::regex!("[a-fA-F0-9]{6}").is_match(c) { | ||||
|                 return Err(AccommodationListControllerErr::MalformedColor.into()); | ||||
|             } | ||||
|         } | ||||
|         accommodation.color.clone_from(&self.color); | ||||
|  | ||||
|         accommodation.need_validation = self.need_validation; | ||||
|         accommodation.open_to_reservations = self.open_to_reservations; | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Create a new accommodation | ||||
| pub async fn create( | ||||
|     m: FamilyInPathWithAdminMembership, | ||||
|     req: web::Json<AccommodationRequest>, | ||||
| ) -> HttpResult { | ||||
|     let mut accommodation = accommodations_list_service::create(m.family_id()).await?; | ||||
|  | ||||
|     if let Err(e) = req.0.to_accommodation(&mut accommodation).await { | ||||
|         log::error!("Failed to apply accommodation information! {e}"); | ||||
|         accommodations_list_service::delete(&mut accommodation).await?; | ||||
|         return Ok(HttpResponse::BadRequest().body(e.to_string())); | ||||
|     } | ||||
|  | ||||
|     if let Err(e) = accommodations_list_service::update(&mut accommodation).await { | ||||
|         log::error!("Failed to update accommodation information! {e}"); | ||||
|         accommodations_list_service::delete(&mut accommodation).await?; | ||||
|         return Ok(HttpResponse::InternalServerError().finish()); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(accommodation)) | ||||
| } | ||||
|  | ||||
| /// Get the full list of accommodations | ||||
| pub async fn get_full_list(m: FamilyInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .json(accommodations_list_service::get_all_of_family(m.family_id()).await?)) | ||||
| } | ||||
|  | ||||
| /// Get the information of a single accommodation | ||||
| pub async fn get_single(m: FamilyAndAccommodationInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(&m.to_accommodation())) | ||||
| } | ||||
|  | ||||
| /// Update an accommodation | ||||
| pub async fn update( | ||||
|     m: FamilyAndAccommodationInPath, | ||||
|     req: web::Json<AccommodationRequest>, | ||||
|     _admin: FamilyInPathWithAdminMembership, | ||||
| ) -> HttpResult { | ||||
|     let mut accommodation = m.to_accommodation(); | ||||
|  | ||||
|     if let Err(e) = req.0.to_accommodation(&mut accommodation).await { | ||||
|         log::error!("Failed to parse accommodation information! {e}"); | ||||
|         return Ok(HttpResponse::BadRequest().body(e.to_string())); | ||||
|     } | ||||
|  | ||||
|     accommodations_list_service::update(&mut accommodation).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
|  | ||||
| /// Delete an accommodation | ||||
| pub async fn delete( | ||||
|     m: FamilyAndAccommodationInPath, | ||||
|     _admin: FamilyInPathWithAdminMembership, | ||||
| ) -> HttpResult { | ||||
|     accommodations_list_service::delete(&mut m.to_accommodation()).await?; | ||||
|     Ok(HttpResponse::Ok().finish()) | ||||
| } | ||||
| @@ -0,0 +1,163 @@ | ||||
| use ical::{generator::*, *}; | ||||
|  | ||||
| use actix_web::{web, HttpResponse}; | ||||
| use chrono::DateTime; | ||||
|  | ||||
| use crate::constants::StaticConstraints; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::extractors::accommodation_reservation_calendar_extractor::FamilyAndAccommodationReservationCalendarInPath; | ||||
| use crate::extractors::family_extractor::FamilyInPath; | ||||
| use crate::models::{AccommodationID, ReservationStatus}; | ||||
| use crate::services::{ | ||||
|     accommodations_list_service, accommodations_reservations_calendars_service, | ||||
|     accommodations_reservations_service, families_service, | ||||
| }; | ||||
| use crate::utils::time_utils::time; | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct CreateCalendarQuery { | ||||
|     accommodation_id: Option<AccommodationID>, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| /// Create a calendar | ||||
| pub async fn create(a: FamilyInPath, req: web::Json<CreateCalendarQuery>) -> HttpResult { | ||||
|     let accommodation_id = match req.accommodation_id { | ||||
|         Some(i) => { | ||||
|             let accommodation = match accommodations_list_service::get_by_id(i).await { | ||||
|                 Ok(a) => a, | ||||
|                 Err(e) => { | ||||
|                     log::error!("Failed to get accommodation information! {e}"); | ||||
|                     return Ok(HttpResponse::NotFound() | ||||
|                         .json("The accommodation was not found in the family!")); | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             if accommodation.family_id() != a.family_id() { | ||||
|                 return Ok( | ||||
|                     HttpResponse::NotFound().json("The accommodation was not found in the family!") | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             Some(accommodation.id()) | ||||
|         } | ||||
|         None => None, | ||||
|     }; | ||||
|  | ||||
|     let conf = StaticConstraints::default(); | ||||
|     if !conf.accommodation_calendar_name_len.validate(&req.name) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid accommodation name!")); | ||||
|     } | ||||
|  | ||||
|     let calendar = accommodations_reservations_calendars_service::create( | ||||
|         a.user_id(), | ||||
|         a.family_id(), | ||||
|         accommodation_id, | ||||
|         &req.name, | ||||
|     ) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(calendar)) | ||||
| } | ||||
|  | ||||
| /// Get the list of calendars of a user | ||||
| pub async fn get_list(a: FamilyInPath) -> HttpResult { | ||||
|     let users = | ||||
|         accommodations_reservations_calendars_service::get_all_of_user(a.user_id(), a.family_id()) | ||||
|             .await?; | ||||
|     Ok(HttpResponse::Ok().json(users)) | ||||
| } | ||||
|  | ||||
| /// Delete a calendar | ||||
| pub async fn delete(resa: FamilyAndAccommodationReservationCalendarInPath) -> HttpResult { | ||||
|     accommodations_reservations_calendars_service::delete(resa.to_reservation()).await?; | ||||
|     Ok(HttpResponse::Ok().json("Calendar successfully deleted")) | ||||
| } | ||||
|  | ||||
| fn fmt_date(time: i64) -> String { | ||||
|     let res = DateTime::from_timestamp(time, 0).expect("Failed to parse date"); | ||||
|  | ||||
|     /*format!( | ||||
|         "{:0>4}{:0>2}{:0>2}T{:0>2}{:0>2}", | ||||
|         res.year(), | ||||
|         res.month(), | ||||
|         res.day(), | ||||
|         res.minute(), | ||||
|         res.second() | ||||
|     )*/ | ||||
|  | ||||
|     res.format("%Y%m%dT%H%M%S").to_string() | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct AnonymousAccessURL { | ||||
|     token: String, | ||||
| } | ||||
|  | ||||
| /// Get the content of the calendar | ||||
| pub async fn anonymous_access(req: web::Path<AnonymousAccessURL>) -> HttpResult { | ||||
|     let mut calendar = | ||||
|         match accommodations_reservations_calendars_service::get_by_token(&req.token).await { | ||||
|             Ok(c) => c, | ||||
|             Err(e) => { | ||||
|                 log::error!("Calendar information could not be retrieved: {e}"); | ||||
|                 return Ok(HttpResponse::NotFound().body("Calendar not found!")); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|     let accommodations = | ||||
|         accommodations_list_service::get_all_of_family(calendar.family_id()).await?; | ||||
|     let members = families_service::get_memberships_of_family(calendar.family_id()).await?; | ||||
|  | ||||
|     // Get calendar associated events | ||||
|     let events = match calendar.accommodation_id() { | ||||
|         None => { | ||||
|             accommodations_reservations_service::get_all_of_family(calendar.family_id()).await? | ||||
|         } | ||||
|         Some(a) => accommodations_reservations_service::get_all_of_accommodation(a).await?, | ||||
|     }; | ||||
|  | ||||
|     let mut cal = IcalCalendarBuilder::version("2.0") | ||||
|         .gregorian() | ||||
|         .prodid("-//geneit//") | ||||
|         .build(); | ||||
|  | ||||
|     for ev in events { | ||||
|         let accommodation = accommodations | ||||
|             .iter() | ||||
|             .find(|a| a.id() == ev.accommodation_id()) | ||||
|             .unwrap(); | ||||
|         let member_name = members | ||||
|             .iter() | ||||
|             .find(|a| a.membership.user_id() == ev.user_id()) | ||||
|             .map(|m| m.user_name.as_str()) | ||||
|             .unwrap_or("other user"); | ||||
|  | ||||
|         let event = IcalEventBuilder::tzid("Europe/Paris") | ||||
|             .uid(format!("resa-{}", ev.id().0)) | ||||
|             .changed(fmt_date(ev.time_update)) | ||||
|             .start(fmt_date(ev.reservation_start)) | ||||
|             .end(fmt_date(ev.reservation_end)) | ||||
|             .set(ical_property!("SUMMARY", member_name)) | ||||
|             .set(ical_property!("LOCATION", &accommodation.name)) | ||||
|             .set(ical_property!( | ||||
|                 "STATUS", | ||||
|                 match ev.status() { | ||||
|                     ReservationStatus::Pending => "TENTATIVE", | ||||
|                     ReservationStatus::Accepted => "CONFIRMED", | ||||
|                     ReservationStatus::Rejected => "CANCELLED", | ||||
|                 } | ||||
|             )) | ||||
|             .build(); | ||||
|         cal.events.push(event); | ||||
|     } | ||||
|  | ||||
|     if calendar.should_update_last_used() { | ||||
|         calendar.time_used = time() as i64; | ||||
|         accommodations_reservations_calendars_service::update(&calendar).await?; | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .content_type("text/calendar") | ||||
|         .body(cal.generate())) | ||||
| } | ||||
| @@ -0,0 +1,223 @@ | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::extractors::accommodation_extractor::FamilyAndAccommodationInPath; | ||||
| use crate::extractors::accommodation_reservation_extractor::FamilyAndAccommodationReservationInPath; | ||||
| use crate::extractors::family_extractor::FamilyInPath; | ||||
| use crate::models::{Accommodation, AccommodationReservationID, NewAccommodationReservation}; | ||||
| use crate::services::accommodations_reservations_service; | ||||
| use crate::utils::time_utils::time; | ||||
| use actix_web::{web, HttpResponse}; | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct UpdateReservationQuery { | ||||
|     start: usize, | ||||
|     end: usize, | ||||
| } | ||||
|  | ||||
| impl UpdateReservationQuery { | ||||
|     /// Check whether a reservation request is valid or not | ||||
|     async fn validate( | ||||
|         &self, | ||||
|         a: &Accommodation, | ||||
|         resa_id: Option<AccommodationReservationID>, | ||||
|     ) -> anyhow::Result<Option<&str>> { | ||||
|         if !a.open_to_reservations { | ||||
|             return Ok(Some( | ||||
|                 "The accommodation is not open to reservations creation / update!", | ||||
|             )); | ||||
|         } | ||||
|  | ||||
|         if (self.start as i64) < (time() as i64 - 3600 * 24 * 30) { | ||||
|             return Ok(Some("Start time is too far in the past!")); | ||||
|         } | ||||
|  | ||||
|         if self.start == self.end { | ||||
|             return Ok(Some("Start and end time must be different!")); | ||||
|         } | ||||
|  | ||||
|         if self.start > self.end { | ||||
|             return Ok(Some("End time happens before start time!")); | ||||
|         } | ||||
|  | ||||
|         let existing = accommodations_reservations_service::get_reservations_for_time_interval( | ||||
|             a.id(), | ||||
|             self.start, | ||||
|             self.end, | ||||
|         ) | ||||
|         .await?; | ||||
|  | ||||
|         if existing | ||||
|             .iter() | ||||
|             .any(|r| r.validated != Some(false) && resa_id != Some(r.id())) | ||||
|         { | ||||
|             return Ok(Some("This reservation is in conflict with another one!")); | ||||
|         } | ||||
|  | ||||
|         Ok(None) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Create a reservation | ||||
| pub async fn create_reservation( | ||||
|     a: FamilyAndAccommodationInPath, | ||||
|     req: web::Json<UpdateReservationQuery>, | ||||
| ) -> HttpResult { | ||||
|     if let Some(err) = req.validate(&a, None).await? { | ||||
|         return Ok(HttpResponse::BadRequest().json(err)); | ||||
|     } | ||||
|  | ||||
|     let mut reservation = | ||||
|         accommodations_reservations_service::create(&NewAccommodationReservation { | ||||
|             family_id: a.family_id().0, | ||||
|             accommodation_id: a.id().0, | ||||
|             user_id: a.membership().user_id().0, | ||||
|             time_create: time() as i64, | ||||
|             time_update: time() as i64, | ||||
|             reservation_start: req.start as i64, | ||||
|             reservation_end: req.end as i64, | ||||
|         }) | ||||
|         .await?; | ||||
|  | ||||
|     // Auto validate reservation if requested | ||||
|     if !a.need_validation { | ||||
|         reservation.validated = Some(true); | ||||
|  | ||||
|         accommodations_reservations_service::update(&mut reservation).await?; | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(reservation)) | ||||
| } | ||||
|  | ||||
| /// Get the reservations for a given accommodation | ||||
| pub async fn get_accommodation_reservations(a: FamilyAndAccommodationInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .json(accommodations_reservations_service::get_all_of_accommodation(a.id()).await?)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct CheckAvailabilityQuery { | ||||
|     start: usize, | ||||
|     end: usize, | ||||
| } | ||||
|  | ||||
| /// Check reservation availability | ||||
| pub async fn get_accommodation_reservations_for_interval( | ||||
|     a: FamilyAndAccommodationInPath, | ||||
|     req: web::Query<CheckAvailabilityQuery>, | ||||
| ) -> HttpResult { | ||||
|     if req.start > req.end { | ||||
|         return Ok(HttpResponse::BadRequest().json("start should be smaller than end!")); | ||||
|     } | ||||
|  | ||||
|     let res = accommodations_reservations_service::get_reservations_for_time_interval( | ||||
|         a.id(), | ||||
|         req.start, | ||||
|         req.end, | ||||
|     ) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(res)) | ||||
| } | ||||
|  | ||||
| /// Get the full list of accommodations reservations for a family | ||||
| pub async fn full_list(m: FamilyInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .json(accommodations_reservations_service::get_all_of_family(m.family_id()).await?)) | ||||
| } | ||||
|  | ||||
| /// Get a single accommodation reservation | ||||
| pub async fn get_single(m: FamilyAndAccommodationReservationInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(m.to_reservation())) | ||||
| } | ||||
|  | ||||
| /// Update a reservation | ||||
| pub async fn update_single( | ||||
|     m: FamilyAndAccommodationReservationInPath, | ||||
|     req: web::Json<UpdateReservationQuery>, | ||||
| ) -> HttpResult { | ||||
|     if let Some(err) = req.validate(m.as_accommodation(), Some(m.id())).await? { | ||||
|         return Ok(HttpResponse::BadRequest().json(err)); | ||||
|     } | ||||
|  | ||||
|     if m.membership().user_id() != m.user_id() { | ||||
|         return Ok( | ||||
|             HttpResponse::BadRequest().json("Only the owner of a reservation can change it!") | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     let need_validation = m.as_accommodation().need_validation; | ||||
|  | ||||
|     let mut reservation = m.to_reservation(); | ||||
|     reservation.reservation_start = req.start as i64; | ||||
|     reservation.reservation_end = req.end as i64; | ||||
|  | ||||
|     if need_validation { | ||||
|         reservation.validated = None; | ||||
|     } | ||||
|  | ||||
|     accommodations_reservations_service::update(&mut reservation).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
|  | ||||
| /// Delete a reservation | ||||
| pub async fn delete(m: FamilyAndAccommodationReservationInPath) -> HttpResult { | ||||
|     if m.membership().user_id() != m.user_id() { | ||||
|         return Ok( | ||||
|             HttpResponse::BadRequest().json("Only the owner of a reservation can delete it!") | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     accommodations_reservations_service::delete(m.to_reservation()).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct ValidateQuery { | ||||
|     validate: bool, | ||||
| } | ||||
|  | ||||
| /// Validate or reject a reservation | ||||
| pub async fn validate_or_reject( | ||||
|     m: FamilyAndAccommodationReservationInPath, | ||||
|     q: web::Json<ValidateQuery>, | ||||
| ) -> HttpResult { | ||||
|     if !m.membership().is_admin { | ||||
|         return Ok( | ||||
|             HttpResponse::BadRequest().json("Only a family admin can validate a reservation!") | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     if m.validated == Some(q.validate) { | ||||
|         return Ok( | ||||
|             HttpResponse::AlreadyReported().json("This reservation has already been processed!") | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     // In case of re-validation, check that the time is still available | ||||
|     if m.validated == Some(false) && q.validate { | ||||
|         let potential_conflicts = | ||||
|             accommodations_reservations_service::get_reservations_for_time_interval( | ||||
|                 m.accommodation_id(), | ||||
|                 m.reservation_start as usize, | ||||
|                 m.reservation_end as usize, | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         if potential_conflicts | ||||
|             .iter() | ||||
|             .any(|a| a.validated != Some(false)) | ||||
|         { | ||||
|             return Ok(HttpResponse::Conflict().json( | ||||
|                 "This cannot be accepted as it would create a conflict with another reservation!", | ||||
|             )); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Update reservation validation status | ||||
|     let mut reservation = m.to_reservation(); | ||||
|     reservation.validated = Some(q.validate); | ||||
|     accommodations_reservations_service::update(&mut reservation).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
| @@ -80,6 +80,7 @@ struct RichFamilyInfo { | ||||
|     #[serde(flatten)] | ||||
|     membership: FamilyMembership, | ||||
|     enable_genealogy: bool, | ||||
|     enable_accommodations: bool, | ||||
|     disable_couple_photos: bool, | ||||
| } | ||||
|  | ||||
| @@ -90,6 +91,7 @@ pub async fn single_info(f: FamilyInPath) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(RichFamilyInfo { | ||||
|         membership, | ||||
|         enable_genealogy: family.enable_genealogy, | ||||
|         enable_accommodations: family.enable_accommodations, | ||||
|         disable_couple_photos: family.disable_couple_photos, | ||||
|     })) | ||||
| } | ||||
| @@ -105,6 +107,7 @@ pub async fn leave(f: FamilyInPath) -> HttpResult { | ||||
| pub struct UpdateFamilyBody { | ||||
|     name: Option<String>, | ||||
|     enable_genealogy: Option<bool>, | ||||
|     enable_accommodations: Option<bool>, | ||||
|     disable_couple_photos: Option<bool>, | ||||
| } | ||||
|  | ||||
| @@ -127,6 +130,10 @@ pub async fn update( | ||||
|         family.enable_genealogy = enable_genealogy; | ||||
|     } | ||||
|  | ||||
|     if let Some(enable_accommodations) = req.enable_accommodations { | ||||
|         family.enable_accommodations = enable_accommodations; | ||||
|     } | ||||
|  | ||||
|     if let Some(disable_couple_photos) = req.disable_couple_photos { | ||||
|         family.disable_couple_photos = disable_couple_photos; | ||||
|     } | ||||
|   | ||||
| @@ -5,6 +5,9 @@ use actix_web::HttpResponse; | ||||
| use std::fmt::{Debug, Display, Formatter}; | ||||
| use zip::result::ZipError; | ||||
|  | ||||
| pub mod accommodations_list_controller; | ||||
| pub mod accommodations_reservations_calendars_controller; | ||||
| pub mod accommodations_reservations_controller; | ||||
| pub mod auth_controller; | ||||
| pub mod couples_controller; | ||||
| pub mod data_controller; | ||||
|   | ||||
							
								
								
									
										83
									
								
								geneit_backend/src/extractors/accommodation_extractor.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								geneit_backend/src/extractors/accommodation_extractor.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| use crate::extractors::family_extractor::FamilyInPath; | ||||
| use crate::models::{Accommodation, AccommodationID, FamilyID, Membership}; | ||||
| use crate::services::accommodations_list_service; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{FromRequest, HttpRequest}; | ||||
| use serde::Deserialize; | ||||
| use std::ops::Deref; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum AccommodationExtractorErr { | ||||
|     #[error("Accommodation {0:?} does not belong to family {1:?}!")] | ||||
|     AccommodationNotInFamily(AccommodationID, FamilyID), | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct FamilyAndAccommodationInPath(Membership, Accommodation); | ||||
|  | ||||
| impl FamilyAndAccommodationInPath { | ||||
|     async fn load_accommodation_from_path( | ||||
|         family: FamilyInPath, | ||||
|         accommodation_id: AccommodationID, | ||||
|     ) -> anyhow::Result<Self> { | ||||
|         let accommodation = accommodations_list_service::get_by_id(accommodation_id).await?; | ||||
|         if accommodation.family_id() != family.family_id() { | ||||
|             return Err(AccommodationExtractorErr::AccommodationNotInFamily( | ||||
|                 accommodation.id(), | ||||
|                 family.family_id(), | ||||
|             ) | ||||
|             .into()); | ||||
|         } | ||||
|  | ||||
|         Ok(Self(family.into(), accommodation)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Deref for FamilyAndAccommodationInPath { | ||||
|     type Target = Accommodation; | ||||
|  | ||||
|     fn deref(&self) -> &Self::Target { | ||||
|         &self.1 | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl FamilyAndAccommodationInPath { | ||||
|     pub fn membership(&self) -> &Membership { | ||||
|         &self.0 | ||||
|     } | ||||
|  | ||||
|     pub fn to_accommodation(self) -> Accommodation { | ||||
|         self.1 | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct AccommodationIDInPath { | ||||
|     accommodation_id: AccommodationID, | ||||
| } | ||||
|  | ||||
| impl FromRequest for FamilyAndAccommodationInPath { | ||||
|     type Error = actix_web::Error; | ||||
|     type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; | ||||
|  | ||||
|     fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { | ||||
|         let req = req.clone(); | ||||
|         Box::pin(async move { | ||||
|             let family = FamilyInPath::extract(&req).await?; | ||||
|  | ||||
|             let accommodation_id = actix_web::web::Path::<AccommodationIDInPath>::from_request( | ||||
|                 &req, | ||||
|                 &mut Payload::None, | ||||
|             ) | ||||
|             .await? | ||||
|             .accommodation_id; | ||||
|  | ||||
|             Self::load_accommodation_from_path(family, accommodation_id) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     log::error!("Failed to extract accommodation ID from URL! {}", e); | ||||
|                     actix_web::error::ErrorNotFound("Could not fetch accommodation information!") | ||||
|                 }) | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| use crate::extractors::family_extractor::FamilyInPath; | ||||
| use crate::models::{ | ||||
|     AccommodationReservationCalendar, AccommodationReservationCalendarID, FamilyID, Membership, | ||||
| }; | ||||
| use crate::services::accommodations_reservations_calendars_service; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{FromRequest, HttpRequest}; | ||||
| use serde::Deserialize; | ||||
| use std::ops::Deref; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum AccommodationCalendarExtractorErr { | ||||
|     #[error("Calendar {0:?} does not belong to user or family {1:?}!")] | ||||
|     CalendarNotOfUserOrFamily(AccommodationReservationCalendarID, FamilyID), | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct FamilyAndAccommodationReservationCalendarInPath( | ||||
|     Membership, | ||||
|     AccommodationReservationCalendar, | ||||
| ); | ||||
|  | ||||
| impl FamilyAndAccommodationReservationCalendarInPath { | ||||
|     async fn load_calendar_from_path( | ||||
|         family: FamilyInPath, | ||||
|         calendar_id: AccommodationReservationCalendarID, | ||||
|     ) -> anyhow::Result<Self> { | ||||
|         let accommodation = | ||||
|             accommodations_reservations_calendars_service::get_by_id(calendar_id).await?; | ||||
|         if accommodation.family_id() != family.family_id() | ||||
|             || accommodation.user_id() != family.user_id() | ||||
|         { | ||||
|             return Err( | ||||
|                 AccommodationCalendarExtractorErr::CalendarNotOfUserOrFamily( | ||||
|                     accommodation.id(), | ||||
|                     family.family_id(), | ||||
|                 ) | ||||
|                 .into(), | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         Ok(Self(family.into(), accommodation)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Deref for FamilyAndAccommodationReservationCalendarInPath { | ||||
|     type Target = AccommodationReservationCalendar; | ||||
|  | ||||
|     fn deref(&self) -> &Self::Target { | ||||
|         &self.1 | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl FamilyAndAccommodationReservationCalendarInPath { | ||||
|     pub fn membership(&self) -> &Membership { | ||||
|         &self.0 | ||||
|     } | ||||
|  | ||||
|     pub fn to_reservation(self) -> AccommodationReservationCalendar { | ||||
|         self.1 | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct AccommodationIDInPath { | ||||
|     cal_id: AccommodationReservationCalendarID, | ||||
| } | ||||
|  | ||||
| impl FromRequest for FamilyAndAccommodationReservationCalendarInPath { | ||||
|     type Error = actix_web::Error; | ||||
|     type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; | ||||
|  | ||||
|     fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { | ||||
|         let req = req.clone(); | ||||
|         Box::pin(async move { | ||||
|             let family = FamilyInPath::extract(&req).await?; | ||||
|  | ||||
|             let accommodation_id = actix_web::web::Path::<AccommodationIDInPath>::from_request( | ||||
|                 &req, | ||||
|                 &mut Payload::None, | ||||
|             ) | ||||
|             .await? | ||||
|             .cal_id; | ||||
|  | ||||
|             Self::load_calendar_from_path(family, accommodation_id) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     log::error!("Failed to extract calendar ID from URL! {}", e); | ||||
|                     actix_web::error::ErrorNotFound("Could not fetch calendar information!") | ||||
|                 }) | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,103 @@ | ||||
| use crate::extractors::family_extractor::FamilyInPath; | ||||
| use crate::models::{ | ||||
|     Accommodation, AccommodationReservation, AccommodationReservationID, FamilyID, Membership, | ||||
| }; | ||||
| use crate::services::{accommodations_list_service, accommodations_reservations_service}; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{FromRequest, HttpRequest}; | ||||
| use serde::Deserialize; | ||||
| use std::fmt::Debug; | ||||
| use std::ops::Deref; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum AccommodationReservationExtractorErr { | ||||
|     #[error("Accommodation reservation {0:?} does not belong to family {1:?}!")] | ||||
|     AccommodationNotInFamily(AccommodationReservationID, FamilyID), | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct FamilyAndAccommodationReservationInPath( | ||||
|     Membership, | ||||
|     Accommodation, | ||||
|     AccommodationReservation, | ||||
| ); | ||||
|  | ||||
| impl FamilyAndAccommodationReservationInPath { | ||||
|     async fn load_accommodation_reservation_from_path( | ||||
|         family: FamilyInPath, | ||||
|         reservation_id: AccommodationReservationID, | ||||
|     ) -> anyhow::Result<Self> { | ||||
|         let reservation = accommodations_reservations_service::get_by_id(reservation_id).await?; | ||||
|         let accommodation = | ||||
|             accommodations_list_service::get_by_id(reservation.accommodation_id()).await?; | ||||
|  | ||||
|         if accommodation.family_id() != family.family_id() | ||||
|             || reservation.family_id() != family.family_id() | ||||
|         { | ||||
|             return Err( | ||||
|                 AccommodationReservationExtractorErr::AccommodationNotInFamily( | ||||
|                     reservation.id(), | ||||
|                     family.family_id(), | ||||
|                 ) | ||||
|                 .into(), | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         Ok(Self(family.into(), accommodation, reservation)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Deref for FamilyAndAccommodationReservationInPath { | ||||
|     type Target = AccommodationReservation; | ||||
|  | ||||
|     fn deref(&self) -> &Self::Target { | ||||
|         &self.2 | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl FamilyAndAccommodationReservationInPath { | ||||
|     pub fn membership(&self) -> &Membership { | ||||
|         &self.0 | ||||
|     } | ||||
|  | ||||
|     pub fn as_accommodation(&self) -> &Accommodation { | ||||
|         &self.1 | ||||
|     } | ||||
|  | ||||
|     pub fn to_accommodation(self) -> Accommodation { | ||||
|         self.1 | ||||
|     } | ||||
|  | ||||
|     pub fn to_reservation(self) -> AccommodationReservation { | ||||
|         self.2 | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct ReservationIDInPath { | ||||
|     reservation_id: AccommodationReservationID, | ||||
| } | ||||
|  | ||||
| impl FromRequest for FamilyAndAccommodationReservationInPath { | ||||
|     type Error = actix_web::Error; | ||||
|     type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; | ||||
|  | ||||
|     fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { | ||||
|         let req = req.clone(); | ||||
|         Box::pin(async move { | ||||
|             let family = FamilyInPath::extract(&req).await?; | ||||
|  | ||||
|             let reservation_id = | ||||
|                 actix_web::web::Path::<ReservationIDInPath>::from_request(&req, &mut Payload::None) | ||||
|                     .await? | ||||
|                     .reservation_id; | ||||
|  | ||||
|             Self::load_accommodation_reservation_from_path(family, reservation_id) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     log::error!("Failed to extract accommodation ID from URL! {}", e); | ||||
|                     actix_web::error::ErrorNotFound("Could not fetch accommodation information!") | ||||
|                 }) | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +1,6 @@ | ||||
| pub mod accommodation_extractor; | ||||
| pub mod accommodation_reservation_calendar_extractor; | ||||
| pub mod accommodation_reservation_extractor; | ||||
| pub mod couple_extractor; | ||||
| pub mod family_extractor; | ||||
| pub mod member_extractor; | ||||
|   | ||||
| @@ -6,8 +6,10 @@ use actix_web::{web, App, HttpServer}; | ||||
| use geneit_backend::app_config::AppConfig; | ||||
| use geneit_backend::connections::{db_connection, s3_connection}; | ||||
| use geneit_backend::controllers::{ | ||||
|     auth_controller, couples_controller, data_controller, families_controller, members_controller, | ||||
|     photos_controller, server_controller, users_controller, | ||||
|     accommodations_list_controller, accommodations_reservations_calendars_controller, | ||||
|     accommodations_reservations_controller, auth_controller, couples_controller, data_controller, | ||||
|     families_controller, members_controller, photos_controller, server_controller, | ||||
|     users_controller, | ||||
| }; | ||||
|  | ||||
| #[actix_web::main] | ||||
| @@ -204,6 +206,79 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/family/{id}/genealogy/data/import", | ||||
|                 web::put().to(data_controller::import_family), | ||||
|             ) | ||||
|             // [ACCOMODATIONS] List controller | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/list/create", | ||||
|                 web::post().to(accommodations_list_controller::create), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/list/list", | ||||
|                 web::get().to(accommodations_list_controller::get_full_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/list/{accommodation_id}", | ||||
|                 web::get().to(accommodations_list_controller::get_single), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/list/{accommodation_id}", | ||||
|                 web::put().to(accommodations_list_controller::update), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/list/{accommodation_id}", | ||||
|                 web::delete().to(accommodations_list_controller::delete), | ||||
|             ) | ||||
|             // [ACCOMODATIONS] Reservations controller | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations/accommodation/{accommodation_id}", | ||||
|                 web::get() | ||||
|                     .to(accommodations_reservations_controller::get_accommodation_reservations), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations/accommodation/{accommodation_id}/for_interval", | ||||
|                 web::get() | ||||
|                     .to(accommodations_reservations_controller::get_accommodation_reservations_for_interval), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations/full_list", | ||||
|                 web::get().to(accommodations_reservations_controller::full_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations/accommodation/{accommodation_id}/create", | ||||
|                 web::post().to(accommodations_reservations_controller::create_reservation), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservation/{reservation_id}", | ||||
|                 web::get().to(accommodations_reservations_controller::get_single), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservation/{reservation_id}", | ||||
|                 web::patch().to(accommodations_reservations_controller::update_single), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservation/{reservation_id}", | ||||
|                 web::delete().to(accommodations_reservations_controller::delete), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservation/{reservation_id}/validate", | ||||
|                 web::post().to(accommodations_reservations_controller::validate_or_reject), | ||||
|             ) | ||||
|             // [ACCOMMODATIONS] Calendars controller | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations_calendars/create", | ||||
|                 web::post().to(accommodations_reservations_calendars_controller::create), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations_calendars/list", | ||||
|                 web::get().to(accommodations_reservations_calendars_controller::get_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/family/{id}/accommodations/reservations_calendars/{cal_id}", | ||||
|                 web::delete().to(accommodations_reservations_calendars_controller::delete), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/acccommodations_calendar/{token}", | ||||
|                 web::get().to(accommodations_reservations_calendars_controller::anonymous_access), | ||||
|             ) | ||||
|             // Photos controller | ||||
|             .route( | ||||
|                 "/photo/{id}", | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::schema::{couples, families, members, memberships, photos, users}; | ||||
| use crate::constants; | ||||
| use crate::schema::{ | ||||
|     accommodations_list, accommodations_reservations, accommodations_reservations_cals_urls, | ||||
|     couples, families, members, memberships, photos, users, | ||||
| }; | ||||
| use crate::utils::crypt_utils::sha256; | ||||
| use crate::utils::time_utils::time; | ||||
| use diesel::prelude::*; | ||||
|  | ||||
| /// User ID holder | ||||
| @@ -66,6 +71,7 @@ pub struct Family { | ||||
|     pub invitation_code: String, | ||||
|     pub disable_couple_photos: bool, | ||||
|     pub enable_genealogy: bool, | ||||
|     pub enable_accommodations: bool, | ||||
| } | ||||
|  | ||||
| impl Family { | ||||
| @@ -308,7 +314,7 @@ pub struct NewMember { | ||||
|     pub time_update: i64, | ||||
| } | ||||
|  | ||||
| /// Member ID holder | ||||
| /// Couple ID holder | ||||
| #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] | ||||
| pub struct CoupleID(pub i32); | ||||
|  | ||||
| @@ -441,3 +447,153 @@ pub struct NewCouple { | ||||
|     pub time_create: i64, | ||||
|     pub time_update: i64, | ||||
| } | ||||
|  | ||||
| /// Accommodation ID holder | ||||
| #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] | ||||
| pub struct AccommodationID(pub i32); | ||||
|  | ||||
| #[derive(Queryable, Debug, serde::Serialize)] | ||||
| pub struct Accommodation { | ||||
|     id: i32, | ||||
|     family_id: i32, | ||||
|     time_create: i64, | ||||
|     pub time_update: i64, | ||||
|     pub name: String, | ||||
|     pub need_validation: bool, | ||||
|     pub description: Option<String>, | ||||
|     pub color: Option<String>, | ||||
|     pub open_to_reservations: bool, | ||||
| } | ||||
|  | ||||
| impl Accommodation { | ||||
|     pub fn id(&self) -> AccommodationID { | ||||
|         AccommodationID(self.id) | ||||
|     } | ||||
|  | ||||
|     pub fn family_id(&self) -> FamilyID { | ||||
|         FamilyID(self.family_id) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Insertable)] | ||||
| #[diesel(table_name = accommodations_list)] | ||||
| pub struct NewAccommodation { | ||||
|     pub family_id: i32, | ||||
|     pub name: String, | ||||
|     pub time_create: i64, | ||||
|     pub time_update: i64, | ||||
| } | ||||
|  | ||||
| /// Accommodation reservation ID holder | ||||
| #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] | ||||
| pub struct AccommodationReservationID(pub i32); | ||||
|  | ||||
| pub enum ReservationStatus { | ||||
|     Pending, | ||||
|     Accepted, | ||||
|     Rejected, | ||||
| } | ||||
|  | ||||
| #[derive(Queryable, Debug, serde::Serialize)] | ||||
| pub struct AccommodationReservation { | ||||
|     id: i32, | ||||
|     family_id: i32, | ||||
|     accommodation_id: i32, | ||||
|     user_id: i32, | ||||
|     time_create: i64, | ||||
|     pub time_update: i64, | ||||
|     pub reservation_start: i64, | ||||
|     pub reservation_end: i64, | ||||
|     pub validated: Option<bool>, | ||||
| } | ||||
|  | ||||
| impl AccommodationReservation { | ||||
|     pub fn id(&self) -> AccommodationReservationID { | ||||
|         AccommodationReservationID(self.id) | ||||
|     } | ||||
|  | ||||
|     pub fn accommodation_id(&self) -> AccommodationID { | ||||
|         AccommodationID(self.accommodation_id) | ||||
|     } | ||||
|  | ||||
|     pub fn family_id(&self) -> FamilyID { | ||||
|         FamilyID(self.family_id) | ||||
|     } | ||||
|  | ||||
|     pub fn user_id(&self) -> UserID { | ||||
|         UserID(self.user_id) | ||||
|     } | ||||
|  | ||||
|     pub fn status(&self) -> ReservationStatus { | ||||
|         match self.validated { | ||||
|             None => ReservationStatus::Pending, | ||||
|             Some(true) => ReservationStatus::Accepted, | ||||
|             Some(false) => ReservationStatus::Rejected, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Insertable)] | ||||
| #[diesel(table_name = accommodations_reservations)] | ||||
| pub struct NewAccommodationReservation { | ||||
|     pub family_id: i32, | ||||
|     pub accommodation_id: i32, | ||||
|     pub user_id: i32, | ||||
|     pub time_create: i64, | ||||
|     pub time_update: i64, | ||||
|     pub reservation_start: i64, | ||||
|     pub reservation_end: i64, | ||||
| } | ||||
|  | ||||
| /// Accommodation reservation calendar ID holder | ||||
| #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] | ||||
| pub struct AccommodationReservationCalendarID(pub i32); | ||||
|  | ||||
| #[derive(Queryable, Debug, serde::Serialize)] | ||||
| pub struct AccommodationReservationCalendar { | ||||
|     id: i32, | ||||
|     family_id: i32, | ||||
|     accommodation_id: Option<i32>, | ||||
|     user_id: i32, | ||||
|     name: String, | ||||
|     token: String, | ||||
|     pub time_create: i64, | ||||
|     pub time_used: i64, | ||||
| } | ||||
|  | ||||
| impl AccommodationReservationCalendar { | ||||
|     pub fn id(&self) -> AccommodationReservationCalendarID { | ||||
|         AccommodationReservationCalendarID(self.id) | ||||
|     } | ||||
|  | ||||
|     pub fn accommodation_id(&self) -> Option<AccommodationID> { | ||||
|         self.accommodation_id.map(AccommodationID) | ||||
|     } | ||||
|  | ||||
|     pub fn family_id(&self) -> FamilyID { | ||||
|         FamilyID(self.family_id) | ||||
|     } | ||||
|  | ||||
|     pub fn user_id(&self) -> UserID { | ||||
|         UserID(self.user_id) | ||||
|     } | ||||
|  | ||||
|     pub fn should_update_last_used(&self) -> bool { | ||||
|         (self.time_used | ||||
|             + constants::ACCOMMODATIONS_RESERVATIONS_CAL_URL_TIME_USED_UPDATE_MIN_INTERVAL.as_secs() | ||||
|                 as i64) | ||||
|             < time() as i64 | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Insertable)] | ||||
| #[diesel(table_name = accommodations_reservations_cals_urls)] | ||||
| pub struct NewAccommodationReservationCalendar { | ||||
|     pub family_id: i32, | ||||
|     pub accommodation_id: Option<i32>, | ||||
|     pub user_id: i32, | ||||
|     pub name: String, | ||||
|     pub token: String, | ||||
|     pub time_create: i64, | ||||
|     pub time_used: i64, | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,50 @@ | ||||
| // @generated automatically by Diesel CLI. | ||||
|  | ||||
| diesel::table! { | ||||
|     accommodations_list (id) { | ||||
|         id -> Int4, | ||||
|         family_id -> Int4, | ||||
|         time_create -> Int8, | ||||
|         time_update -> Int8, | ||||
|         #[max_length = 50] | ||||
|         name -> Varchar, | ||||
|         need_validation -> Bool, | ||||
|         description -> Nullable<Text>, | ||||
|         #[max_length = 6] | ||||
|         color -> Nullable<Varchar>, | ||||
|         open_to_reservations -> Bool, | ||||
|     } | ||||
| } | ||||
|  | ||||
| diesel::table! { | ||||
|     accommodations_reservations (id) { | ||||
|         id -> Int4, | ||||
|         family_id -> Int4, | ||||
|         accommodation_id -> Int4, | ||||
|         user_id -> Int4, | ||||
|         time_create -> Int8, | ||||
|         time_update -> Int8, | ||||
|         reservation_start -> Int8, | ||||
|         reservation_end -> Int8, | ||||
|         validated -> Nullable<Bool>, | ||||
|     } | ||||
| } | ||||
|  | ||||
| diesel::table! { | ||||
|     accommodations_reservations_cals_urls (id) { | ||||
|         id -> Int4, | ||||
|         family_id -> Int4, | ||||
|         accommodation_id -> Nullable<Int4>, | ||||
|         user_id -> Int4, | ||||
|         #[max_length = 50] | ||||
|         name -> Varchar, | ||||
|         #[max_length = 50] | ||||
|         token -> Varchar, | ||||
|         time_create -> Int8, | ||||
|         time_used -> Int8, | ||||
|     } | ||||
| } | ||||
|  | ||||
| diesel::table! { | ||||
|     couples (id) { | ||||
|         id -> Int4, | ||||
| @@ -30,6 +75,7 @@ diesel::table! { | ||||
|         invitation_code -> Varchar, | ||||
|         disable_couple_photos -> Bool, | ||||
|         enable_genealogy -> Bool, | ||||
|         enable_accommodations -> Bool, | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -119,6 +165,13 @@ diesel::table! { | ||||
|     } | ||||
| } | ||||
|  | ||||
| diesel::joinable!(accommodations_list -> families (family_id)); | ||||
| diesel::joinable!(accommodations_reservations -> accommodations_list (accommodation_id)); | ||||
| diesel::joinable!(accommodations_reservations -> families (family_id)); | ||||
| diesel::joinable!(accommodations_reservations -> users (user_id)); | ||||
| diesel::joinable!(accommodations_reservations_cals_urls -> accommodations_list (accommodation_id)); | ||||
| diesel::joinable!(accommodations_reservations_cals_urls -> families (family_id)); | ||||
| diesel::joinable!(accommodations_reservations_cals_urls -> users (user_id)); | ||||
| diesel::joinable!(couples -> families (family_id)); | ||||
| diesel::joinable!(couples -> photos (photo_id)); | ||||
| diesel::joinable!(members -> families (family_id)); | ||||
| @@ -127,6 +180,9 @@ diesel::joinable!(memberships -> families (family_id)); | ||||
| diesel::joinable!(memberships -> users (user_id)); | ||||
|  | ||||
| diesel::allow_tables_to_appear_in_same_query!( | ||||
|     accommodations_list, | ||||
|     accommodations_reservations, | ||||
|     accommodations_reservations_cals_urls, | ||||
|     couples, | ||||
|     families, | ||||
|     members, | ||||
|   | ||||
							
								
								
									
										103
									
								
								geneit_backend/src/services/accommodations_list_service.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								geneit_backend/src/services/accommodations_list_service.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| use crate::connections::db_connection; | ||||
| use crate::models::{Accommodation, AccommodationID, FamilyID, NewAccommodation}; | ||||
| use crate::schema::accommodations_list; | ||||
| use crate::utils::time_utils::time; | ||||
| use diesel::prelude::*; | ||||
|  | ||||
| /// Create a new accommodation | ||||
| pub async fn create(family_id: FamilyID) -> anyhow::Result<Accommodation> { | ||||
|     db_connection::execute(|conn| { | ||||
|         let res: Accommodation = diesel::insert_into(accommodations_list::table) | ||||
|             .values(&NewAccommodation { | ||||
|                 family_id: family_id.0, | ||||
|                 name: "".to_string(), | ||||
|                 time_create: time() as i64, | ||||
|                 time_update: time() as i64, | ||||
|             }) | ||||
|             .get_result(conn)?; | ||||
|  | ||||
|         Ok(res) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get the information of an accommodation | ||||
| pub async fn get_by_id(id: AccommodationID) -> anyhow::Result<Accommodation> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_list::table | ||||
|             .filter(accommodations_list::dsl::id.eq(id.0)) | ||||
|             .first(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get all the accommodations of a family | ||||
| pub async fn get_all_of_family(id: FamilyID) -> anyhow::Result<Vec<Accommodation>> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_list::table | ||||
|             .filter(accommodations_list::dsl::family_id.eq(id.0)) | ||||
|             .get_results(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Check whether accommodation with a given id exists or not | ||||
| pub async fn exists( | ||||
|     family_id: FamilyID, | ||||
|     accommodation_id: AccommodationID, | ||||
| ) -> anyhow::Result<bool> { | ||||
|     db_connection::execute(|conn| { | ||||
|         let count: i64 = accommodations_list::table | ||||
|             .filter( | ||||
|                 accommodations_list::id | ||||
|                     .eq(accommodation_id.0) | ||||
|                     .and(accommodations_list::family_id.eq(family_id.0)), | ||||
|             ) | ||||
|             .count() | ||||
|             .get_result(conn)?; | ||||
|  | ||||
|         Ok(count != 0) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Update the information of an accommodation | ||||
| pub async fn update(accommodation: &mut Accommodation) -> anyhow::Result<()> { | ||||
|     accommodation.time_update = time() as i64; | ||||
|  | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::update( | ||||
|             accommodations_list::dsl::accommodations_list | ||||
|                 .filter(accommodations_list::dsl::id.eq(accommodation.id().0)), | ||||
|         ) | ||||
|         .set(( | ||||
|             accommodations_list::dsl::time_update.eq(accommodation.time_update), | ||||
|             accommodations_list::dsl::name.eq(accommodation.name.to_string()), | ||||
|             accommodations_list::dsl::need_validation.eq(accommodation.need_validation), | ||||
|             accommodations_list::dsl::description.eq(accommodation.description.clone()), | ||||
|             accommodations_list::dsl::color.eq(accommodation.color.clone()), | ||||
|             accommodations_list::dsl::open_to_reservations.eq(accommodation.open_to_reservations), | ||||
|         )) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Delete an accommodation | ||||
| pub async fn delete(accommodation: &mut Accommodation) -> anyhow::Result<()> { | ||||
|     // Remove the accommodation | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::delete( | ||||
|             accommodations_list::dsl::accommodations_list | ||||
|                 .filter(accommodations_list::dsl::id.eq(accommodation.id().0)), | ||||
|         ) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Delete all the accommodations of a family | ||||
| pub async fn delete_all_family(family_id: FamilyID) -> anyhow::Result<()> { | ||||
|     for mut m in get_all_of_family(family_id).await? { | ||||
|         delete(&mut m).await?; | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
| @@ -0,0 +1,98 @@ | ||||
| use crate::connections::db_connection; | ||||
| use crate::constants; | ||||
| use crate::models::{ | ||||
|     AccommodationID, AccommodationReservationCalendar, AccommodationReservationCalendarID, | ||||
|     FamilyID, NewAccommodationReservationCalendar, UserID, | ||||
| }; | ||||
| use crate::schema::accommodations_reservations_cals_urls; | ||||
| use crate::utils::string_utils::rand_str; | ||||
| use crate::utils::time_utils::time; | ||||
| use diesel::prelude::*; | ||||
|  | ||||
| /// Create a new reservation calendar entry | ||||
| pub async fn create( | ||||
|     user_id: UserID, | ||||
|     family_id: FamilyID, | ||||
|     accommodation_id: Option<AccommodationID>, | ||||
|     name: &str, | ||||
| ) -> anyhow::Result<AccommodationReservationCalendar> { | ||||
|     db_connection::execute(|conn| { | ||||
|         let res: AccommodationReservationCalendar = | ||||
|             diesel::insert_into(accommodations_reservations_cals_urls::table) | ||||
|                 .values(&NewAccommodationReservationCalendar { | ||||
|                     family_id: family_id.0, | ||||
|                     accommodation_id: accommodation_id.map(|i| i.0), | ||||
|                     user_id: user_id.0, | ||||
|                     name: name.to_string(), | ||||
|                     token: rand_str(constants::ACCOMMODATIONS_RESERVATIONS_CALENDARS_TOKENS_LEN), | ||||
|                     time_create: time() as i64, | ||||
|                     time_used: time() as i64, | ||||
|                 }) | ||||
|                 .get_result(conn)?; | ||||
|  | ||||
|         Ok(res) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Update the information of a reservations calendar | ||||
| pub async fn update(cal: &AccommodationReservationCalendar) -> anyhow::Result<()> { | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::update( | ||||
|             accommodations_reservations_cals_urls::dsl::accommodations_reservations_cals_urls | ||||
|                 .filter(accommodations_reservations_cals_urls::dsl::id.eq(cal.id().0)), | ||||
|         ) | ||||
|         .set((accommodations_reservations_cals_urls::dsl::time_used.eq(cal.time_used),)) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Get all the calendars of a user | ||||
| pub async fn get_all_of_user( | ||||
|     user: UserID, | ||||
|     family: FamilyID, | ||||
| ) -> anyhow::Result<Vec<AccommodationReservationCalendar>> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations_cals_urls::table | ||||
|             .filter( | ||||
|                 accommodations_reservations_cals_urls::dsl::family_id | ||||
|                     .eq(family.0) | ||||
|                     .and(accommodations_reservations_cals_urls::dsl::user_id.eq(user.0)), | ||||
|             ) | ||||
|             .get_results(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get a single calendar by its id | ||||
| pub async fn get_by_id( | ||||
|     id: AccommodationReservationCalendarID, | ||||
| ) -> anyhow::Result<AccommodationReservationCalendar> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations_cals_urls::table | ||||
|             .filter(accommodations_reservations_cals_urls::dsl::id.eq(id.0)) | ||||
|             .get_result(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get a single calendar by its token | ||||
| pub async fn get_by_token(token: &str) -> anyhow::Result<AccommodationReservationCalendar> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations_cals_urls::table | ||||
|             .filter(accommodations_reservations_cals_urls::dsl::token.eq(token)) | ||||
|             .get_result(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Delete a calendar | ||||
| pub async fn delete(r: AccommodationReservationCalendar) -> anyhow::Result<()> { | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::delete( | ||||
|             accommodations_reservations_cals_urls::dsl::accommodations_reservations_cals_urls | ||||
|                 .filter(accommodations_reservations_cals_urls::dsl::id.eq(r.id().0)), | ||||
|         ) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| use crate::connections::db_connection; | ||||
| use crate::models::{ | ||||
|     AccommodationID, AccommodationReservation, AccommodationReservationID, FamilyID, | ||||
|     NewAccommodationReservation, | ||||
| }; | ||||
| use crate::schema::accommodations_reservations; | ||||
| use crate::utils::time_utils::time; | ||||
| use diesel::prelude::*; | ||||
|  | ||||
| /// Create a new reservation | ||||
| pub async fn create(new: &NewAccommodationReservation) -> anyhow::Result<AccommodationReservation> { | ||||
|     db_connection::execute(|conn| { | ||||
|         let res: AccommodationReservation = diesel::insert_into(accommodations_reservations::table) | ||||
|             .values(new) | ||||
|             .get_result(conn)?; | ||||
|  | ||||
|         Ok(res) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Update a reservation | ||||
| pub async fn update(r: &mut AccommodationReservation) -> anyhow::Result<()> { | ||||
|     r.time_update = time() as i64; | ||||
|  | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::update( | ||||
|             accommodations_reservations::dsl::accommodations_reservations | ||||
|                 .filter(accommodations_reservations::dsl::id.eq(r.id().0)), | ||||
|         ) | ||||
|         .set(( | ||||
|             accommodations_reservations::dsl::time_update.eq(r.time_update), | ||||
|             accommodations_reservations::dsl::validated.eq(r.validated), | ||||
|             accommodations_reservations::dsl::reservation_start.eq(r.reservation_start), | ||||
|             accommodations_reservations::dsl::reservation_end.eq(r.reservation_end), | ||||
|         )) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Delete a reservation | ||||
| pub async fn delete(r: AccommodationReservation) -> anyhow::Result<()> { | ||||
|     // Remove the reservation | ||||
|     db_connection::execute(|conn| { | ||||
|         diesel::delete( | ||||
|             accommodations_reservations::dsl::accommodations_reservations | ||||
|                 .filter(accommodations_reservations::dsl::id.eq(r.id().0)), | ||||
|         ) | ||||
|         .execute(conn) | ||||
|     })?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Get all the reservations of an accommodation | ||||
| pub async fn get_all_of_accommodation( | ||||
|     id: AccommodationID, | ||||
| ) -> anyhow::Result<Vec<AccommodationReservation>> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations::table | ||||
|             .filter(accommodations_reservations::dsl::accommodation_id.eq(id.0)) | ||||
|             .get_results(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get all the reservations of a family | ||||
| pub async fn get_all_of_family(id: FamilyID) -> anyhow::Result<Vec<AccommodationReservation>> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations::table | ||||
|             .filter(accommodations_reservations::dsl::family_id.eq(id.0)) | ||||
|             .get_results(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get a single accommodation reservation by its id | ||||
| pub async fn get_by_id(id: AccommodationReservationID) -> anyhow::Result<AccommodationReservation> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations::table | ||||
|             .filter(accommodations_reservations::dsl::id.eq(id.0)) | ||||
|             .get_result(conn) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /// Get the reservations that are between a given interval of time for a given accommodation | ||||
| pub async fn get_reservations_for_time_interval( | ||||
|     id: AccommodationID, | ||||
|     start: usize, | ||||
|     end: usize, | ||||
| ) -> anyhow::Result<Vec<AccommodationReservation>> { | ||||
|     db_connection::execute(|conn| { | ||||
|         accommodations_reservations::table | ||||
|             .filter( | ||||
|                 accommodations_reservations::dsl::accommodation_id | ||||
|                     .eq(id.0) | ||||
|                     .and(accommodations_reservations::dsl::reservation_start.lt((end) as i64)) | ||||
|                     .and(accommodations_reservations::dsl::reservation_end.gt((start) as i64)), | ||||
|             ) | ||||
|             .get_results(conn) | ||||
|     }) | ||||
| } | ||||
| @@ -5,7 +5,9 @@ use crate::models::{ | ||||
|     Family, FamilyID, FamilyMembership, Membership, NewFamily, NewMembership, UserID, | ||||
| }; | ||||
| use crate::schema::{families, memberships}; | ||||
| use crate::services::{couples_service, members_service, users_service}; | ||||
| use crate::services::{ | ||||
|     accommodations_list_service, couples_service, members_service, users_service, | ||||
| }; | ||||
| use crate::utils::string_utils::rand_str; | ||||
| use crate::utils::time_utils::time; | ||||
| use diesel::prelude::*; | ||||
| @@ -127,9 +129,9 @@ pub async fn update_membership(membership: &Membership) -> anyhow::Result<()> { | ||||
| #[derive(serde::Serialize)] | ||||
| pub struct FamilyMember { | ||||
|     #[serde(flatten)] | ||||
|     membership: Membership, | ||||
|     user_name: String, | ||||
|     user_mail: String, | ||||
|     pub membership: Membership, | ||||
|     pub user_name: String, | ||||
|     pub user_mail: String, | ||||
| } | ||||
|  | ||||
| /// Get information about the users of a family | ||||
| @@ -175,6 +177,7 @@ pub async fn update_family(family: &Family) -> anyhow::Result<()> { | ||||
|                 families::dsl::name.eq(family.name.clone()), | ||||
|                 families::dsl::invitation_code.eq(family.invitation_code.clone()), | ||||
|                 families::dsl::enable_genealogy.eq(family.enable_genealogy), | ||||
|                 families::dsl::enable_accommodations.eq(family.enable_accommodations), | ||||
|                 families::dsl::disable_couple_photos.eq(family.disable_couple_photos), | ||||
|             )) | ||||
|             .execute(conn) | ||||
| @@ -185,6 +188,9 @@ pub async fn update_family(family: &Family) -> anyhow::Result<()> { | ||||
|  | ||||
| /// Delete a family | ||||
| pub async fn delete_family(family_id: FamilyID) -> anyhow::Result<()> { | ||||
|     // Delete all family accommodations | ||||
|     accommodations_list_service::delete_all_family(family_id).await?; | ||||
|  | ||||
|     // Delete all family couples | ||||
|     couples_service::delete_all_family(family_id).await?; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,8 @@ | ||||
| //! # Backend services | ||||
|  | ||||
| pub mod accommodations_list_service; | ||||
| pub mod accommodations_reservations_calendars_service; | ||||
| pub mod accommodations_reservations_service; | ||||
| pub mod couples_service; | ||||
| pub mod families_service; | ||||
| pub mod login_token_service; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user