diff --git a/package.json b/package.json index a3161774..5ca9a1b4 100644 --- a/package.json +++ b/package.json @@ -48,16 +48,17 @@ "@tanstack/query-broadcast-client-experimental": "^5.51.17", "@tanstack/react-query": "^5.51.18", "@tanstack/react-query-devtools": "^5.51.18", - "@tanstack/react-router": "^1.46.3", + "@tanstack/react-router": "^1.46.4", "@tanstack/react-table": "^8.19.3", "@tanstack/react-virtual": "^3.8.4", - "@tanstack/router-devtools": "^1.46.3", + "@tanstack/router-devtools": "^1.46.4", "@ts-rest/core": "^3.48.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "compare-versions": "^6.1.1", "date-fns": "^3.6.0", + "framer-motion": "^11.3.21", "i18next": "^23.12.2", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a0c118..1c67483a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,8 +90,8 @@ importers: specifier: ^5.51.18 version: 5.51.18(@tanstack/react-query@5.51.18(react@18.3.1))(react@18.3.1) '@tanstack/react-router': - specifier: ^1.46.3 - version: 1.46.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^1.46.4 + version: 1.46.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-table': specifier: ^8.19.3 version: 8.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -99,8 +99,8 @@ importers: specifier: ^3.8.4 version: 3.8.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/router-devtools': - specifier: ^1.46.3 - version: 1.46.3(@tanstack/react-router@1.46.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^1.46.4 + version: 1.46.4(@tanstack/react-router@1.46.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ts-rest/core': specifier: ^3.48.1 version: 3.48.1(zod@3.23.8) @@ -119,6 +119,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + framer-motion: + specifier: ^11.3.21 + version: 11.3.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) i18next: specifier: ^23.12.2 version: 23.12.2 @@ -1558,8 +1561,8 @@ packages: peerDependencies: react: ^18.0.0 - '@tanstack/react-router@1.46.3': - resolution: {integrity: sha512-NVu/bQofLJIPQyq7W2drhi5veiDK1TwCvKIVCO3zBkkA4X7N2Bq6EqIbocwsN6wmHHmfKs7ovsSfz2dKQ4T0gg==} + '@tanstack/react-router@1.46.4': + resolution: {integrity: sha512-ELuHRaUCswAPwzrdf+X1BStKbogEQ82qW9Nyl0FfdK3qta7b28frBD9mmDOeyHW/Ff+OhWhY3KgpeViHef/+LQ==} engines: {node: '>=12'} peerDependencies: react: '>=18' @@ -1584,11 +1587,11 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@tanstack/router-devtools@1.46.3': - resolution: {integrity: sha512-nGHtuH5a/0vLdMwDI2FubzyUZZSw8KbrXaLB4uej8vyGFOX70v3GOhN6n7bFAPT1i3SxE5uIyRq7/agC91xgTw==} + '@tanstack/router-devtools@1.46.4': + resolution: {integrity: sha512-MtOctTsHVwcBAixFrDv+gvlhMS3RhVvQHeRLt9qRCepUILilffTTWLGKhbarVRWuRsxBhQeTd4uIrFSKCKbSRA==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.46.3 + '@tanstack/react-router': ^1.46.4 react: '>=18' react-dom: '>=18' @@ -2260,6 +2263,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@11.3.21: + resolution: {integrity: sha512-D+hfIsvzV8eL/iycld4K+tKlg2Q2LdwnrcBEohtGw3cG1AIuNYATbT5RUqIM1ndsAk+EfGhoSGf0UaiFodc5Tw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4813,7 +4830,7 @@ snapshots: '@tanstack/query-core': 5.51.17 react: 18.3.1 - '@tanstack/react-router@1.46.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-router@1.46.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.45.3 '@tanstack/react-store': 0.5.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4841,9 +4858,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/router-devtools@1.46.3(@tanstack/react-router@1.46.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/router-devtools@1.46.4(@tanstack/react-router@1.46.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/react-router': 1.46.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-router': 1.46.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 goober: 2.1.14(csstype@3.1.3) react: 18.3.1 @@ -5719,6 +5736,13 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@11.3.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + tslib: 2.6.2 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fsevents@2.3.3: optional: true diff --git a/src/lib/api/_digitalSignature.contract.ts b/src/lib/api/_digitalSignature.contract.ts new file mode 100644 index 00000000..0d2e23a2 --- /dev/null +++ b/src/lib/api/_digitalSignature.contract.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { c } from "@/lib/api/c"; +import { DigitalSignatureDriverSchema } from "@/lib/schemas/digital-signature/driverList"; + +import { StructuredErrorSchema, UnauthorizedErrorSchema } from "./helpers"; + +const rootDigitalSignatureContract = c.router({ + getDriversList: { + method: "GET", + path: "/v3/digitalsignature/additionaldriverlist", + query: z.object({ + agreementId: z.string().optional(), + reservationId: z.string().optional(), + }), + responses: { + 200: z.array(DigitalSignatureDriverSchema), + }, + }, + getDigitalSignatureImageUrl: { + method: "POST", + path: "/v3/digitalsignature/reloadsignatureimageurl", + responses: { + 200: z.string(), + 204: z.string().or(z.undefined()), + 401: UnauthorizedErrorSchema, + 404: StructuredErrorSchema, + }, + body: z.object({ + isCheckin: z.boolean(), + agreementId: z.string(), + signatureImageUrl: z.string(), + }), + }, +}); + +export { rootDigitalSignatureContract }; diff --git a/src/lib/api/_root.contract.ts b/src/lib/api/_root.contract.ts index ef97078e..47fe9b7e 100644 --- a/src/lib/api/_root.contract.ts +++ b/src/lib/api/_root.contract.ts @@ -2,6 +2,7 @@ import { rootAgreementContract } from "./_agreement.contract"; import { rootClientContract } from "./_client.contract"; import { rootCustomerContract } from "./_customer.contract"; import { rootDashboardContract } from "./_dashboard.contract"; +import { rootDigitalSignatureContract } from "./_digitalSignature.contract"; import { rootLocationContract } from "./_location.contract"; import { rootMiscChargeContract } from "./_misc-charge.contract"; import { rootNoteContract } from "./_note.contract"; @@ -24,6 +25,7 @@ const contract = c.router( client: rootClientContract, customer: rootCustomerContract, dashboard: rootDashboardContract, + digitalSignature: rootDigitalSignatureContract, location: rootLocationContract, miscCharge: rootMiscChargeContract, note: rootNoteContract, diff --git a/src/lib/query/digitalSignature.ts b/src/lib/query/digitalSignature.ts new file mode 100644 index 00000000..d858cac2 --- /dev/null +++ b/src/lib/query/digitalSignature.ts @@ -0,0 +1,69 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiClient } from "@/lib/api"; + +import { isEnabled, makeQueryKey } from "./helpers"; +import type { Auth, Enabled } from "./helpers"; + +const SEGMENT = "digital_signature"; + +export function fetchDigitalSignatureDriversList( + options: { + agreementId?: string; + reservationId?: string; + } & Auth & + Enabled +) { + const { enabled = true } = options; + + return queryOptions({ + queryKey: makeQueryKey(options, [ + SEGMENT, + options.agreementId + ? `agreement_${options.agreementId}` + : `reservation_${options.reservationId}`, + ]), + queryFn: () => + apiClient.digitalSignature + .getDriversList({ + query: { + agreementId: options.agreementId, + reservationId: options.reservationId, + }, + }) + .then((res) => ({ ...res, headers: null })), + enabled: isEnabled(options) && enabled, + }); +} + +export function fetchAgreementDigitalSignatureUrl( + options: { + agreementId: string; + driverId: string | null; + isCheckin: boolean; + signatureImageUrl: string; + } & Auth & + Enabled +) { + const { enabled = true } = options; + + return queryOptions({ + queryKey: makeQueryKey(options, [ + SEGMENT, + `agreement_${options.agreementId}`, + options.driverId || "no-driver-id", + `checkin_${options.isCheckin}`, + ]), + queryFn: () => + apiClient.digitalSignature + .getDigitalSignatureImageUrl({ + body: { + agreementId: options.agreementId, + isCheckin: options.isCheckin, + signatureImageUrl: options.signatureImageUrl, + }, + }) + .then((res) => ({ ...res, headers: null })), + enabled: isEnabled(options) && enabled, + }); +} diff --git a/src/lib/schemas/digital-signature/driverList.ts b/src/lib/schemas/digital-signature/driverList.ts new file mode 100644 index 00000000..fea9f4b8 --- /dev/null +++ b/src/lib/schemas/digital-signature/driverList.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export const DigitalSignatureDriverSchema = z.object({ + driverId: z.number(), + agreementId: z.number().nullable(), + customerId: z.number().nullable(), + driverName: z.string().nullable(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + dateOfBirth: z.string().nullable(), + driverType: z.number(), + createdBy: z.coerce.string().nullable(), + licenseNumber: z.string().nullable(), + licenseCategory: z.string().nullable(), + licenseExpiryDate: z.string().nullable(), + email: z.string().nullable(), + licenseIssueDate: z.string().nullable(), + licenseIssueState: z.string().nullable(), + hPhone: z.string().nullable(), + bPhone: z.string().nullable(), + cPhone: z.string().nullable(), + address: z.string().nullable(), + city: z.string().nullable(), + stateId: z.number().nullable(), + zipCode: z.string().nullable(), + countryId: z.number().nullable(), + signatureName: z.string().nullable(), + signatureImageUrl: z.string().nullable(), + reservationId: z.number().nullable(), + insuranceCompany: z.string().nullable(), + checkForDelete: z.number().nullable(), + dateofBirth: z.string().nullable(), + dateofBirthStr: z.string().nullable(), + createdDate: z.string().nullable(), + updatedDate: z.string().nullable(), + updateBy: z.coerce.string().nullable(), + driverLicenseNumber: z.string().nullable(), + driverLicenseCategory: z.string().nullable(), + driverLicenseExpiryDate: z.string().nullable(), + isDelete: z.boolean(), + isFromCustomer: z.boolean(), + phone: z.string().nullable(), + signatureImageUrlString: z.string().nullable(), + startDate: z.string().nullable(), + endDate: z.string().nullable(), + referenceType: z.string().nullable(), + referenceId: z.coerce.string().nullable(), + countryName: z.string().nullable(), + signatureDate: z.string().nullable(), +}); +export type DigitalSignatureDriver = z.infer< + typeof DigitalSignatureDriverSchema +>; diff --git a/src/routes/_auth/(agreements)/-components/summary-signature-card.tsx b/src/routes/_auth/(agreements)/-components/summary-signature-card.tsx index 3bea78bc..58893e0e 100644 --- a/src/routes/_auth/(agreements)/-components/summary-signature-card.tsx +++ b/src/routes/_auth/(agreements)/-components/summary-signature-card.tsx @@ -1,16 +1,32 @@ +import * as React from "react"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; + import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { icons } from "@/components/ui/icons"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { useDatePreference } from "@/lib/hooks/useDatePreferences"; import type { AgreementDataParsed } from "@/lib/schemas/agreement"; +import type { DigitalSignatureDriver } from "@/lib/schemas/digital-signature/driverList"; +import { fetchAgreementDigitalSignatureUrl } from "@/lib/query/digitalSignature"; +import type { Auth } from "@/lib/query/helpers"; + +import { format } from "@/lib/config/date-fns"; import { cn } from "@/lib/utils"; -export default function SummarySignatureCard(props: { - agreement: AgreementDataParsed; -}) { +export default function SummarySignatureCard( + props: { + agreement: AgreementDataParsed; + drivers: DigitalSignatureDriver[]; + isCheckin: boolean; + } & Auth +) { const agreementId = props.agreement.agreementId.toString(); - const drivers = props.agreement.driverList; + const drivers = props.drivers; return ( @@ -24,13 +40,50 @@ export default function SummarySignatureCard(props: { @@ -38,53 +91,126 @@ export default function SummarySignatureCard(props: { ); } -function Driver(props: { - driver: AgreementDataParsed["driverList"][number]; +function SignatureImage(props: BaseDriverProps) { + const signatureQuery = useSuspenseQuery( + fetchAgreementDigitalSignatureUrl({ + agreementId: props.agreementId, + driverId: props.driver.driverId.toString(), + isCheckin: props.stage === "checkin", + signatureImageUrl: "", + auth: props.auth, + }) + ); + + const data = + signatureQuery.data.status === 200 ? signatureQuery.data.body : null; + + const dataUrl = React.useMemo( + () => (data ? `data:image/png;base64,${data}` : null), + [data] + ); + + if (!data || !dataUrl) return null; + + return ( +
  • + +
  • + ); +} + +interface BaseDriverProps extends Auth { + stage: "checkout" | "checkin"; agreementId: string; - isPrimary: boolean; -}) { - const isSigned = !!props.driver.signatureDate; + driver: DigitalSignatureDriver; +} + +function DriverController(props: BaseDriverProps) { + return ( + + ); +} + +function AdditionalDriverController(props: BaseDriverProps) { + return ( + + ); +} + +function Driver( + props: BaseDriverProps & { + isSigned: boolean; + isPrimary?: boolean; + } +) { + const { dateTimeFormat } = useDatePreference(); + + const signedDate = props.driver.signatureDate + ? ` ${format(props.driver.signatureDate, dateTimeFormat)}.` + : " ..."; return ( -
  • +
  • {props.isPrimary ? ( - + ) : ( - + )} {props.driver.driverName}

    -

    +

    - {isSigned ? ( - <> - Signed on: -  {props.driver.signatureDate ?? "..."} - + {props.isSigned ? ( + + Signed at + {signedDate} + ) : ( - Not signed + + {props.stage === "checkin" + ? "Did not sign at checkin." + : "Did not sign at checkout."} + )}

    - {isSigned ? ( - - ) : null}
  • ); diff --git a/src/routes/_auth/(agreements)/agreements.$agreementId._details.summary.tsx b/src/routes/_auth/(agreements)/agreements.$agreementId._details.summary.tsx index 1ee1bcb4..a38865e2 100644 --- a/src/routes/_auth/(agreements)/agreements.$agreementId._details.summary.tsx +++ b/src/routes/_auth/(agreements)/agreements.$agreementId._details.summary.tsx @@ -8,6 +8,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useFeature } from "@/lib/hooks/useFeature"; import { useLocalStorage } from "@/lib/hooks/useLocalStorage"; +import { fetchDigitalSignatureDriversList } from "@/lib/query/digitalSignature"; + import CustomerInformation from "@/routes/_auth/-modules/information-block/customer-information"; import RentalInformation from "@/routes/_auth/-modules/information-block/rental-information"; import VehicleInformation from "@/routes/_auth/-modules/information-block/vehicle-information"; @@ -23,6 +25,19 @@ export const Route = createFileRoute( validateSearch: z.object({ summary_tab: z.string().optional(), }), + beforeLoad: ({ context, params }) => { + const { authParams } = context; + return { + digitalSignatureDriversOptions: fetchDigitalSignatureDriversList({ + auth: authParams, + agreementId: params.agreementId, + }), + }; + }, + loader: async ({ context }) => { + const { queryClient, digitalSignatureDriversOptions } = context; + await queryClient.prefetchQuery(digitalSignatureDriversOptions); + }, }); const SummarySignatureCard = React.lazy( () => import("@/routes/_auth/(agreements)/-components/summary-signature-card") @@ -39,8 +54,12 @@ function Component() { return "vehicle"; }, }); - const { viewAgreementOptions, viewAgreementSummaryOptions } = - Route.useRouteContext(); + const { + viewAgreementOptions, + viewAgreementSummaryOptions, + digitalSignatureDriversOptions, + authParams: auth, + } = Route.useRouteContext(); const canViewCustomerInformation = true; const canViewRentalInformation = true; @@ -63,6 +82,14 @@ function Component() { const summaryData = summaryQuery.data?.status === 200 ? summaryQuery.data?.body : undefined; + const signatureDriverListQuery = useSuspenseQuery( + digitalSignatureDriversOptions + ); + const signatureDriverList = + signatureDriverListQuery.data?.status === 200 + ? signatureDriverListQuery.data.body + : []; + const tabsConfig = React.useMemo(() => { const tabs: { id: string; label: string; component: React.ReactNode }[] = []; @@ -206,7 +233,12 @@ function Component() { {showIncompleteAgreementSignature && canViewDigitalSignaturePad && agreement ? ( - + ) : null}