diff --git a/geneit_app/package-lock.json b/geneit_app/package-lock.json index f216a0f..8629442 100644 --- a/geneit_app/package-lock.json +++ b/geneit_app/package-lock.json @@ -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", diff --git a/geneit_app/package.json b/geneit_app/package.json index 4259423..c9a3f05 100644 --- a/geneit_app/package.json +++ b/geneit_app/package.json @@ -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", diff --git a/geneit_app/src/App.tsx b/geneit_app/src/App.tsx index a07dcd1..9299040 100644 --- a/geneit_app/src/App.tsx +++ b/geneit_app/src/App.tsx @@ -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 { } /> + } + > + } /> + } + /> + } + /> + } /> + + } /> } /> } /> diff --git a/geneit_app/src/api/FamilyApi.ts b/geneit_app/src/api/FamilyApi.ts index 31d97b6..590fdd1 100644 --- a/geneit_app/src/api/FamilyApi.ts +++ b/geneit_app/src/api/FamilyApi.ts @@ -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 { 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, }, }); diff --git a/geneit_app/src/api/ServerApi.ts b/geneit_app/src/api/ServerApi.ts index 03a464c..d017de6 100644 --- a/geneit_app/src/api/ServerApi.ts +++ b/geneit_app/src/api/ServerApi.ts @@ -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 { diff --git a/geneit_app/src/api/accommodations/AccommodationListApi.tsx b/geneit_app/src/api/accommodations/AccommodationListApi.tsx new file mode 100644 index 0000000..9a98fed --- /dev/null +++ b/geneit_app/src/api/accommodations/AccommodationListApi.tsx @@ -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; + + 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 { + 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 { + 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 { + 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 { + return ( + await APIClient.exec({ + method: "DELETE", + uri: `/family/${accommodation.family_id}/accommodations/list/${accommodation.id}`, + }) + ).data; + } +} diff --git a/geneit_app/src/api/accommodations/AccommodationsCalendarURLApi.tsx b/geneit_app/src/api/accommodations/AccommodationsCalendarURLApi.tsx new file mode 100644 index 0000000..c263beb --- /dev/null +++ b/geneit_app/src/api/accommodations/AccommodationsCalendarURLApi.tsx @@ -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 { + 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 { + 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 { + return ( + await APIClient.exec({ + method: "DELETE", + uri: `/family/${calendar.family_id}/accommodations/reservations_calendars/${calendar.id}`, + }) + ).data; + } +} diff --git a/geneit_app/src/api/accommodations/AccommodationsReservationsApi.tsx b/geneit_app/src/api/accommodations/AccommodationsReservationsApi.tsx new file mode 100644 index 0000000..23ea118 --- /dev/null +++ b/geneit_app/src/api/accommodations/AccommodationsReservationsApi.tsx @@ -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; + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/geneit_app/src/dialogs/accommodations/CreateAccommodationCalendarURLDialog.tsx b/geneit_app/src/dialogs/accommodations/CreateAccommodationCalendarURLDialog.tsx new file mode 100644 index 0000000..c5846f9 --- /dev/null +++ b/geneit_app/src/dialogs/accommodations/CreateAccommodationCalendarURLDialog.tsx @@ -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({ 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 ( + + Création d'un calendrier + + + setCalendar((a) => { + return { + ...a!, + name: s!, + }; + }) + } + size={ServerApi.Config.constraints.accommodation_calendar_name_len} + helperText={nameErr} + /> + + { + 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"} + /> + + + + + + + ); +} diff --git a/geneit_app/src/dialogs/accommodations/InstallCalendarDialog.tsx b/geneit_app/src/dialogs/accommodations/InstallCalendarDialog.tsx new file mode 100644 index 0000000..91a92fb --- /dev/null +++ b/geneit_app/src/dialogs/accommodations/InstallCalendarDialog.tsx @@ -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 ( + + Installation du calendrier + + + + Afin d'installer le calendrier {p.cal.name} sur votre + appareil, veuillez utiliser l'URL suivante : + +
+ + + + + + + + + } + /> +
+ +
+
+
+
+ + + +
+ ); +} diff --git a/geneit_app/src/dialogs/accommodations/UpdateAccommodationDialog.tsx b/geneit_app/src/dialogs/accommodations/UpdateAccommodationDialog.tsx new file mode 100644 index 0000000..8f179e6 --- /dev/null +++ b/geneit_app/src/dialogs/accommodations/UpdateAccommodationDialog.tsx @@ -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 ( + + + {p.create ? "Création" : "Mise à jour"} d'un logement + + + + setAccommodation((a) => { + return { + ...a!, + name: s!, + }; + }) + } + size={ServerApi.Config.constraints.accommodation_name_len} + helperText={nameErr} + /> + + + setAccommodation((a) => { + return { + ...a!, + description: s!, + }; + }) + } + size={ServerApi.Config.constraints.accommodation_description_len} + helperText={descriptionErr} + /> + + + setAccommodation((a) => { + return { + ...a!, + color: s!, + }; + }) + } + /> + + + setAccommodation((a) => { + return { + ...a!, + open_to_reservations: c, + }; + }) + } + /> + + + + setAccommodation((a) => { + return { + ...a!, + need_validation: c, + }; + }) + } + /> + + + + + + + + ); +} diff --git a/geneit_app/src/dialogs/accommodations/UpdateReservationDialog.tsx b/geneit_app/src/dialogs/accommodations/UpdateReservationDialog.tsx new file mode 100644 index 0000000..b304157 --- /dev/null +++ b/geneit_app/src/dialogs/accommodations/UpdateReservationDialog.tsx @@ -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 ( + + + {p.create ? "Création" : "Mise à jour"} d'une réservation + + + { + 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() + } + /> + + { + setReservation((r) => { + return { ...r!, start: s ?? -1 }; + }); + }} + minDate={Math.floor(new Date().getTime() / 1000) - 3600 * 24 * 60} + canSetMiddleDay + /> + + { + setReservation((r) => { + return { ...r!, end: s ?? -1 }; + }); + }} + minDate={reservation?.start} + canSetMiddleDay + /> + + {conflicts && conflicts.length > 0 && ( + +

+ Cette réservation est en conflit avec d'autres réservations sur + les intervalles suivants : +

+
    + {conflicts.map((c, num) => ( +
  • + Réservation du {fmtUnixDate(c.reservation_start)} au{" "} + {fmtUnixDate(c.reservation_end)} +
  • + ))} +
+
+ )} +
+ + + + +
+ ); +} diff --git a/geneit_app/src/hooks/context_providers/accommodations/CreateAccommodationCalendarURLDialogProvider.tsx b/geneit_app/src/hooks/context_providers/accommodations/CreateAccommodationCalendarURLDialogProvider.tsx new file mode 100644 index 0000000..376329d --- /dev/null +++ b/geneit_app/src/hooks/context_providers/accommodations/CreateAccommodationCalendarURLDialogProvider.tsx @@ -0,0 +1,52 @@ +import React, { PropsWithChildren } from "react"; +import { NewCalendarURL } from "../../../api/accommodations/AccommodationsCalendarURLApi"; +import { CreateAccommodationCalendarURLDialog } from "../../../dialogs/accommodations/CreateAccommodationCalendarURLDialog"; + +type DialogContext = () => Promise; + +const DialogContextK = React.createContext(null); + +export function CreateAccommodationCalendarURLDialogProvider( + p: PropsWithChildren +): React.ReactElement { + const [open, setOpen] = React.useState(false); + + const cb = React.useRef 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 ( + <> + + {p.children} + + + {open && ( + + )} + + ); +} + +export function useCreateAccommodationCalendarURL(): DialogContext { + return React.useContext(DialogContextK)!; +} diff --git a/geneit_app/src/hooks/context_providers/accommodations/InstallCalendarDialogProvider.tsx b/geneit_app/src/hooks/context_providers/accommodations/InstallCalendarDialogProvider.tsx new file mode 100644 index 0000000..61d05cd --- /dev/null +++ b/geneit_app/src/hooks/context_providers/accommodations/InstallCalendarDialogProvider.tsx @@ -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; + +const DialogContextK = React.createContext(null); + +export function InstallCalendarDialogProvider( + p: PropsWithChildren +): React.ReactElement { + const [cal, setCal] = React.useState(); + + const cb = React.useRef 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 ( + <> + + {p.children} + + + {cal && } + + ); +} + +export function useInstallCalendarDialog(): DialogContext { + return React.useContext(DialogContextK)!; +} diff --git a/geneit_app/src/hooks/context_providers/accommodations/UpdateAccommodationDialogProvider.tsx b/geneit_app/src/hooks/context_providers/accommodations/UpdateAccommodationDialogProvider.tsx new file mode 100644 index 0000000..63a6b77 --- /dev/null +++ b/geneit_app/src/hooks/context_providers/accommodations/UpdateAccommodationDialogProvider.tsx @@ -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; + +const DialogContextK = React.createContext(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 ( + <> + + {p.children} + + + {open && ( + + )} + + ); +} + +export function useUpdateAccommodation(): DialogContext { + return React.useContext(DialogContextK)!; +} diff --git a/geneit_app/src/hooks/context_providers/accommodations/UpdateReservationDialogProvider.tsx b/geneit_app/src/hooks/context_providers/accommodations/UpdateReservationDialogProvider.tsx new file mode 100644 index 0000000..e22b6ab --- /dev/null +++ b/geneit_app/src/hooks/context_providers/accommodations/UpdateReservationDialogProvider.tsx @@ -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; + +const DialogContextK = React.createContext(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 ( + <> + + {p.children} + + + {open && ( + + )} + + ); +} + +export function useUpdateAccommodationReservation(): DialogContext { + return React.useContext(DialogContextK)!; +} diff --git a/geneit_app/src/routes/family/FamilySettingsRoute.tsx b/geneit_app/src/routes/family/FamilySettingsRoute.tsx index 7c0d720..c14ddf1 100644 --- a/geneit_app/src/routes/family/FamilySettingsRoute.tsx +++ b/geneit_app/src/routes/family/FamilySettingsRoute.tsx @@ -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} /> - - - + setEnableAccommodations(c)} + /> + } + label="Activer le module de réservation de logements" + /> diff --git a/geneit_app/src/routes/family/accommodations/AccommodationsHomeRoute.tsx b/geneit_app/src/routes/family/accommodations/AccommodationsHomeRoute.tsx new file mode 100644 index 0000000..861573b --- /dev/null +++ b/geneit_app/src/routes/family/accommodations/AccommodationsHomeRoute.tsx @@ -0,0 +1,21 @@ +import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle"; +import { useAccommodations } from "../../../widgets/accommodations/BaseAccommodationsRoute"; + +export function AccommodationsHomeRoute(): React.ReactElement { + const accommodations = useAccommodations(); + return ( + <> + +
+

+ Depuis cette section de l'application, vous pouvez effectuer des + réservations de logements. +

+

 

+

+ Nombre de logements définis : {accommodations.accommodations.size} +

+
+ + ); +} diff --git a/geneit_app/src/routes/family/accommodations/AccommodationsReservationsRoute.tsx b/geneit_app/src/routes/family/accommodations/AccommodationsReservationsRoute.tsx new file mode 100644 index 0000000..97d30b5 --- /dev/null +++ b/geneit_app/src/routes/family/accommodations/AccommodationsReservationsRoute.tsx @@ -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(null); + + const [showValidated, setShowValidated] = React.useState(true); + const [showRejected, setShowRejected] = React.useState(true); + const [showPending, setShowPending] = React.useState(true); + + const [hiddenPeople, setHiddenPeople] = React.useState>( + new Set() + ); + const [hiddenAccommodations, setHiddenAccommodations] = React.useState< + Set + >(new Set()); + + const eventPopupAnchor = React.useRef(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); + + 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 ( + <> + + ( +
+
+ + Cliquez sur le calendrier pour créer une réservation. + + + {/* Invitation status */} + + Status + + setShowValidated(v)} + color="success" + /> + } + label="Validées" + /> + setShowRejected(v)} + color="error" + /> + } + label="Rejetées" + /> + setShowPending(v)} + color="info" + /> + } + label="En attente de validation" + /> + + + + {/* Accommodations */} + + Logements + + {accommodations.accommodations.fullList.map((a) => ( + { + if (v) hiddenAccommodations.delete(a.id); + else hiddenAccommodations.add(a.id); + setHiddenAccommodations( + new Set(hiddenAccommodations) + ); + }} + /> + } + label={a.name} + /> + ))} + + + + {/* People */} + + Personnes + + {users?.map((u) => ( + { + if (v) hiddenPeople.delete(u.user_id); + else hiddenPeople.add(u.user_id); + setHiddenPeople(new Set(hiddenPeople)); + }} + /> + } + label={u.user_name} + /> + ))} + + +
+ + {/* The calendar */} +
+ { + 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, + }, + }; + })} + /> +
+ + {/* Calendar event popover */} +
+ { + setActiveEvent(undefined); + }} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + > + + + {activeEvent?.user.user_name + .substring(0, 1) + .toLocaleUpperCase()} + + } + title={activeEvent?.user.user_name} + subheader={activeEvent?.user.user_mail} + /> + + + +

+ Réservation de {activeEvent?.accommodation.name} +
+ {activeEvent?.accommodation.description} +

+

+ Du{" "} + {fmtUnixDate( + activeEvent?.reservation.reservation_start ?? 0 + )}{" "} +
+ Au{" "} + {fmtUnixDate( + activeEvent?.reservation.reservation_end ?? 0 + )} +

+

+ + {activeEvent?.reservation.validated === false ? ( + Refusée + ) : activeEvent?.reservation.validated === true ? ( + Validée + ) : ( + + En attente de validation + + )} + +

+
+
+ + {activeEvent?.accommodation.need_validation && + family.family.is_admin && ( + <> + + + setValidateResaAnchorEl(e.currentTarget) + } + > + + + + setValidateResaAnchorEl(null)} + > + + validateReservation(activeEvent.reservation) + } + > + Valider + + + rejectReservation(activeEvent.reservation) + } + > + Rejeter + + + + )} + + {user.user.id === activeEvent?.reservation.user_id && ( + <> + + + changeReservation(activeEvent?.reservation) + } + > + + + + + + deleteReservation(activeEvent?.reservation) + } + > + + + + + )} + +
+
+
+ )} + /> + + ); +} diff --git a/geneit_app/src/routes/family/accommodations/AccommodationsSettingsRoute.tsx b/geneit_app/src/routes/family/accommodations/AccommodationsSettingsRoute.tsx new file mode 100644 index 0000000..9022ef5 --- /dev/null +++ b/geneit_app/src/routes/family/accommodations/AccommodationsSettingsRoute.tsx @@ -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 ( + <> + + + + ); +} + +function AccommodationsListCard(): React.ReactElement { + const loading = useLoadingMessage(); + const confirm = useConfirm(); + const snackbar = useSnackbar(); + + const family = useFamily(); + const accommodations = useAccommodations(); + + const [error, setError] = React.useState(); + const [success, setSuccess] = React.useState(); + + 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 ( + + + + Logements + + + {/* Display the list of accommodations */} + {accommodations.accommodations.isEmpty && ( +
+ Aucun logement enregistré pour le moment ! +
+ )} + {accommodations.accommodations.fullList.map((a) => ( + + ))} + + {family.family.is_admin && ( + + )} +
+
+ ); +} + +function AccommodationCard(p: { + accommodation: Accommodation; + onRequestUpdate: (a: Accommodation) => void; + onRequestDelete: (a: Accommodation) => void; +}): React.ReactElement { + const family = useFamily(); + return ( + + + + Mis à jour il y a + + + {" "} + {p.accommodation.name} + + + {p.accommodation.description} + + + Ouvert aux + réservations +
+ Réservation + sans validation d'un administrateur +
+
+ {family.family.is_admin && ( + + + + + + )} +
+ ); +} + +function BoolIcon(p: { checked?: boolean }): React.ReactElement { + return p.checked ? ( + + ) : ( + + ); +} + +function AccommodationsCalURLsCard(): React.ReactElement { + const key = React.useRef(0); + + const confirm = useConfirm(); + const loading = useLoadingMessage(); + + const [error, setError] = React.useState(); + const [success, setSuccess] = React.useState(); + + 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 ( + + + + URL de calendriers + + + 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. + + + + 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. + + + + +
+
+ + + list?.length === 0 ? ( + <> +

+ Vous n'avez créé aucun calendrier pour le moment ! +

+ + ) : ( + <> + {list?.map((c) => ( + + ))} + + ) + } + /> +
+
+ ); +} + +function CalendarItem(p: { + c: AccommodationCalendarURL; + onRequestDelete: (c: AccommodationCalendarURL) => void; +}): React.ReactElement { + const accommodations = useAccommodations(); + + const installCal = useInstallCalendarDialog(); + + return ( + + + + + {p.c.name} + + + {p.c.accommodation_id + ? accommodations.accommodations.get(p.c.accommodation_id)?.name + : "Tous les logements"} + + + Créé il y a +
+ Utilisé il y a +
+
+ + + + + + +
+ ); +} diff --git a/geneit_app/src/utils/form_utils.ts b/geneit_app/src/utils/form_utils.ts new file mode 100644 index 0000000..09336ba --- /dev/null +++ b/geneit_app/src/utils/form_utils.ts @@ -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; +} diff --git a/geneit_app/src/utils/time_utils.ts b/geneit_app/src/utils/time_utils.ts new file mode 100644 index 0000000..aacde37 --- /dev/null +++ b/geneit_app/src/utils/time_utils.ts @@ -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; +} diff --git a/geneit_app/src/widgets/BaseFamilyRoute.tsx b/geneit_app/src/widgets/BaseFamilyRoute.tsx index 38e6830..bb1c64f 100644 --- a/geneit_app/src/widgets/BaseFamilyRoute.tsx +++ b/geneit_app/src/widgets/BaseFamilyRoute.tsx @@ -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 && ( + <> + + Logements + + } + label="Accueil" + uri="accommodations" + /> + } + label="Réservations" + uri="accommodations/reservations" + /> + + )} + Administration @@ -207,6 +227,14 @@ export function BaseFamilyRoute(): React.ReactElement { /> )} + {family?.enable_accommodations && ( + } + label="Logements" + uri="accommodations/settings" + /> + )} + {/* Invitation code */} +): React.ReactElement { + const snackbar = useSnackbar(); + + const copy = () => { + navigator.clipboard.writeText(p.content); + snackbar(`${p.content} a été copié dans le presse papier.`); + }; + + return ( + + {p.children} + + ); +} diff --git a/geneit_app/src/widgets/FamilyCard.tsx b/geneit_app/src/widgets/FamilyCard.tsx index c33f300..eb2bb7d 100644 --- a/geneit_app/src/widgets/FamilyCard.tsx +++ b/geneit_app/src/widgets/FamilyCard.tsx @@ -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 ( - + {p.error && {p.error}} {p.success && {p.success}} diff --git a/geneit_app/src/widgets/accommodations/BaseAccommodationsRoute.tsx b/geneit_app/src/widgets/accommodations/BaseAccommodationsRoute.tsx new file mode 100644 index 0000000..b34ae3d --- /dev/null +++ b/geneit_app/src/widgets/accommodations/BaseAccommodationsRoute.tsx @@ -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; +} + +const AccommodationsContextK = + React.createContext(null); + +export function BaseAccommodationsRoute(): React.ReactElement { + const family = useFamily(); + + const [accommodations, setAccommodations] = + React.useState(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((res, _rej) => { + loadPromise.current = () => res(); + }); + }; + + return ( + { + if (loadPromise.current != null) { + loadPromise.current?.(); + loadPromise.current = undefined; + } + + return ( + + + + + + + + + + + + ); + }} + /> + ); +} + +export function useAccommodations(): AccommodationsContext { + return React.useContext(AccommodationsContextK)!; +} diff --git a/geneit_app/src/widgets/forms/PropCheckbox.tsx b/geneit_app/src/widgets/forms/PropCheckbox.tsx index 1316622..8c26c29 100644 --- a/geneit_app/src/widgets/forms/PropCheckbox.tsx +++ b/geneit_app/src/widgets/forms/PropCheckbox.tsx @@ -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 {p.label}; + if (!p.checkboxAlwaysVisible) { + if (!p.editable && p.checked) + return {p.label}; - if (!p.editable) return <>; + if (!p.editable) return <>; + } return ( p.onValueChange(e.target.checked)} /> diff --git a/geneit_app/src/widgets/forms/PropColorPicker.tsx b/geneit_app/src/widgets/forms/PropColorPicker.tsx new file mode 100644 index 0000000..11205b6 --- /dev/null +++ b/geneit_app/src/widgets/forms/PropColorPicker.tsx @@ -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 ; + } + + return ( + p.onChange(c.hex.substring(1))} + /> + ); +} diff --git a/geneit_app/src/widgets/forms/PropDateInput.tsx b/geneit_app/src/widgets/forms/PropDateInput.tsx new file mode 100644 index 0000000..faafa8f --- /dev/null +++ b/geneit_app/src/widgets/forms/PropDateInput.tsx @@ -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 ( + + ); + } + + 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 ( + <> +
+ + { + 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} + /> + + {p.canSetMiddleDay && ( + { + 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" + /> + )} +
+ + ); +} diff --git a/geneit_app/src/widgets/forms/PropEdit.tsx b/geneit_app/src/widgets/forms/PropEdit.tsx index 217d359..f365bf2 100644 --- a/geneit_app/src/widgets/forms/PropEdit.tsx +++ b/geneit_app/src/widgets/forms/PropEdit.tsx @@ -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} /> ); } diff --git a/geneit_app/src/widgets/forms/PropSelect.tsx b/geneit_app/src/widgets/forms/PropSelect.tsx index e329784..b09a94c 100644 --- a/geneit_app/src/widgets/forms/PropSelect.tsx +++ b/geneit_app/src/widgets/forms/PropSelect.tsx @@ -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 ; } + return ( {p.label} diff --git a/geneit_app/src/widgets/genealogy/BaseGenealogyRoute.tsx b/geneit_app/src/widgets/genealogy/BaseGenealogyRoute.tsx index 247663c..0f0e5d9 100644 --- a/geneit_app/src/widgets/genealogy/BaseGenealogyRoute.tsx +++ b/geneit_app/src/widgets/genealogy/BaseGenealogyRoute.tsx @@ -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; reloadCouplesList: () => Promise; } -const GenealogyContextK = React.createContext(null); +const GenealogyContextK = React.createContext(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)!; } diff --git a/geneit_backend/Cargo.lock b/geneit_backend/Cargo.lock index 6b00cc3..5bd31c1 100644 --- a/geneit_backend/Cargo.lock +++ b/geneit_backend/Cargo.lock @@ -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" diff --git a/geneit_backend/Cargo.toml b/geneit_backend/Cargo.toml index 1fae93a..e7585ed 100644 --- a/geneit_backend/Cargo.toml +++ b/geneit_backend/Cargo.toml @@ -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" \ No newline at end of file diff --git a/geneit_backend/migrations/2024-05-23-163128_accommodation_module/down.sql b/geneit_backend/migrations/2024-05-23-163128_accommodation_module/down.sql new file mode 100644 index 0000000..bf3bfb7 --- /dev/null +++ b/geneit_backend/migrations/2024-05-23-163128_accommodation_module/down.sql @@ -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; \ No newline at end of file diff --git a/geneit_backend/migrations/2024-05-23-163128_accommodation_module/up.sql b/geneit_backend/migrations/2024-05-23-163128_accommodation_module/up.sql new file mode 100644 index 0000000..42d2dd8 --- /dev/null +++ b/geneit_backend/migrations/2024-05-23-163128_accommodation_module/up.sql @@ -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'; diff --git a/geneit_backend/src/constants.rs b/geneit_backend/src/constants.rs index f1edc26..e7a0f3d 100644 --- a/geneit_backend/src/constants.rs +++ b/geneit_backend/src/constants.rs @@ -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); diff --git a/geneit_backend/src/controllers/accommodations_list_controller.rs b/geneit_backend/src/controllers/accommodations_list_controller.rs new file mode 100644 index 0000000..cf640ce --- /dev/null +++ b/geneit_backend/src/controllers/accommodations_list_controller.rs @@ -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, + pub color: Option, + 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, +) -> 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, + _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()) +} diff --git a/geneit_backend/src/controllers/accommodations_reservations_calendars_controller.rs b/geneit_backend/src/controllers/accommodations_reservations_calendars_controller.rs new file mode 100644 index 0000000..3c36396 --- /dev/null +++ b/geneit_backend/src/controllers/accommodations_reservations_calendars_controller.rs @@ -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, + name: String, +} + +/// Create a calendar +pub async fn create(a: FamilyInPath, req: web::Json) -> 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) -> 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())) +} diff --git a/geneit_backend/src/controllers/accommodations_reservations_controller.rs b/geneit_backend/src/controllers/accommodations_reservations_controller.rs new file mode 100644 index 0000000..8c3fba5 --- /dev/null +++ b/geneit_backend/src/controllers/accommodations_reservations_controller.rs @@ -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, + ) -> anyhow::Result> { + 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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()) +} diff --git a/geneit_backend/src/controllers/families_controller.rs b/geneit_backend/src/controllers/families_controller.rs index b5b4c3e..8183db1 100644 --- a/geneit_backend/src/controllers/families_controller.rs +++ b/geneit_backend/src/controllers/families_controller.rs @@ -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, enable_genealogy: Option, + enable_accommodations: Option, disable_couple_photos: Option, } @@ -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; } diff --git a/geneit_backend/src/controllers/mod.rs b/geneit_backend/src/controllers/mod.rs index 0d22c59..17b70a8 100644 --- a/geneit_backend/src/controllers/mod.rs +++ b/geneit_backend/src/controllers/mod.rs @@ -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; diff --git a/geneit_backend/src/extractors/accommodation_extractor.rs b/geneit_backend/src/extractors/accommodation_extractor.rs new file mode 100644 index 0000000..f772ddf --- /dev/null +++ b/geneit_backend/src/extractors/accommodation_extractor.rs @@ -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 { + 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>; + + 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::::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!") + }) + }) + } +} diff --git a/geneit_backend/src/extractors/accommodation_reservation_calendar_extractor.rs b/geneit_backend/src/extractors/accommodation_reservation_calendar_extractor.rs new file mode 100644 index 0000000..449be99 --- /dev/null +++ b/geneit_backend/src/extractors/accommodation_reservation_calendar_extractor.rs @@ -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 { + 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>; + + 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::::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!") + }) + }) + } +} diff --git a/geneit_backend/src/extractors/accommodation_reservation_extractor.rs b/geneit_backend/src/extractors/accommodation_reservation_extractor.rs new file mode 100644 index 0000000..d62d63c --- /dev/null +++ b/geneit_backend/src/extractors/accommodation_reservation_extractor.rs @@ -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 { + 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>; + + 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::::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!") + }) + }) + } +} diff --git a/geneit_backend/src/extractors/mod.rs b/geneit_backend/src/extractors/mod.rs index 0cafc82..05594ba 100644 --- a/geneit_backend/src/extractors/mod.rs +++ b/geneit_backend/src/extractors/mod.rs @@ -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; diff --git a/geneit_backend/src/main.rs b/geneit_backend/src/main.rs index 683dfcb..b4b0f74 100644 --- a/geneit_backend/src/main.rs +++ b/geneit_backend/src/main.rs @@ -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}", diff --git a/geneit_backend/src/models.rs b/geneit_backend/src/models.rs index 596d092..05e8ad6 100644 --- a/geneit_backend/src/models.rs +++ b/geneit_backend/src/models.rs @@ -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, + pub color: Option, + 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, +} + +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, + 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 { + 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, + pub user_id: i32, + pub name: String, + pub token: String, + pub time_create: i64, + pub time_used: i64, +} diff --git a/geneit_backend/src/schema.rs b/geneit_backend/src/schema.rs index f681f84..655f46f 100644 --- a/geneit_backend/src/schema.rs +++ b/geneit_backend/src/schema.rs @@ -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, + #[max_length = 6] + color -> Nullable, + 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, + } +} + +diesel::table! { + accommodations_reservations_cals_urls (id) { + id -> Int4, + family_id -> Int4, + accommodation_id -> Nullable, + 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, diff --git a/geneit_backend/src/services/accommodations_list_service.rs b/geneit_backend/src/services/accommodations_list_service.rs new file mode 100644 index 0000000..ba0f7e8 --- /dev/null +++ b/geneit_backend/src/services/accommodations_list_service.rs @@ -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 { + 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 { + 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> { + 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 { + 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(()) +} diff --git a/geneit_backend/src/services/accommodations_reservations_calendars_service.rs b/geneit_backend/src/services/accommodations_reservations_calendars_service.rs new file mode 100644 index 0000000..c38d17a --- /dev/null +++ b/geneit_backend/src/services/accommodations_reservations_calendars_service.rs @@ -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, + name: &str, +) -> anyhow::Result { + 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> { + 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 { + 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 { + 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(()) +} diff --git a/geneit_backend/src/services/accommodations_reservations_service.rs b/geneit_backend/src/services/accommodations_reservations_service.rs new file mode 100644 index 0000000..0cd018b --- /dev/null +++ b/geneit_backend/src/services/accommodations_reservations_service.rs @@ -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 { + 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> { + 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> { + 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 { + 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> { + 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) + }) +} diff --git a/geneit_backend/src/services/families_service.rs b/geneit_backend/src/services/families_service.rs index af6dd93..5cb47b5 100644 --- a/geneit_backend/src/services/families_service.rs +++ b/geneit_backend/src/services/families_service.rs @@ -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?; diff --git a/geneit_backend/src/services/mod.rs b/geneit_backend/src/services/mod.rs index 935229e..cd5c67f 100644 --- a/geneit_backend/src/services/mod.rs +++ b/geneit_backend/src/services/mod.rs @@ -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;