diff --git a/package-lock.json b/package-lock.json index 1509789f..d31efe5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,12 +25,15 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", + "@types/leaflet": "^1.9.12", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", "content-disposition": "^0.5.4", "dayjs": "^1.11.10", "embla-carousel-react": "^8.0.0-rc21", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", "next": "^14.2.3", @@ -44,6 +47,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.47.0", "react-intersection-observer": "^9.8.0", + "react-leaflet": "^4.2.1", "sharp": "^0.33.2", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", @@ -2406,6 +2410,17 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz", @@ -3110,12 +3125,27 @@ "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/leaflet": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz", + "integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.8.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", @@ -3805,12 +3835,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -5295,9 +5325,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6361,6 +6391,18 @@ "language-subtag-registry": "~0.3.2" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet-defaulticon-compatibility": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz", + "integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7540,6 +7582,20 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", diff --git a/package.json b/package.json index 17e66865..54e509ee 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "content-disposition": "^0.5.4", "dayjs": "^1.11.10", "embla-carousel-react": "^8.0.0-rc21", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", "next": "^14.2.3", @@ -50,6 +52,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.47.0", "react-intersection-observer": "^9.8.0", + "react-leaflet": "^4.2.1", "sharp": "^0.33.2", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", @@ -61,6 +64,7 @@ "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", "@types/content-disposition": "^0.5.8", + "@types/leaflet": "^1.9.12", "@types/node": "^20", "@types/pg": "^8.10.9", "@types/react": "^18.2.48", diff --git a/prisma/migrations/20240618134226_add_location/migration.sql b/prisma/migrations/20240618134226_add_location/migration.sql new file mode 100644 index 00000000..9cdac04e --- /dev/null +++ b/prisma/migrations/20240618134226_add_location/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "Point" ( + "id" TEXT NOT NULL, + "latitude" DOUBLE PRECISION NOT NULL, + "longitude" DOUBLE PRECISION NOT NULL, + + CONSTRAINT "Point_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Point" ADD CONSTRAINT "Point_id_fkey" FOREIGN KEY ("id") REFERENCES "Expense"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240619112134_alter_point_add_on_delete_cascade/migration.sql b/prisma/migrations/20240619112134_alter_point_add_on_delete_cascade/migration.sql new file mode 100644 index 00000000..08c89380 --- /dev/null +++ b/prisma/migrations/20240619112134_alter_point_add_on_delete_cascade/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Point" DROP CONSTRAINT "Point_id_fkey"; + +-- AddForeignKey +ALTER TABLE "Point" ADD CONSTRAINT "Point_id_fkey" FOREIGN KEY ("id") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2989f3c9..18238235 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,14 @@ model Expense { createdAt DateTime @default(now()) documents ExpenseDocument[] notes String? + location Point? +} + +model Point { + id String @id + latitude Float + longitude Float + Expense Expense? @relation(fields: [id], references: [id], onDelete: Cascade) } model ExpenseDocument { diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index 758e93d1..6053c311 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -45,12 +45,13 @@ import { cn } from '@/lib/utils' import { zodResolver } from '@hookform/resolvers/zod' import { Save } from 'lucide-react' import Link from 'next/link' -import { useSearchParams } from 'next/navigation' +import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { DeletePopup } from './delete-popup' import { extractCategoryFromTitle } from './expense-form-actions' +import { ExpenseLocationInput } from './expense-location-input' import { Textarea } from './ui/textarea' export type Props = { @@ -146,6 +147,17 @@ async function persistDefaultSplittingOptions( } } +function getLocationFromSearchParams( + searchParams: ReadonlyURLSearchParams, +): ExpenseFormValues['location'] { + return searchParams.get('latitude') && searchParams.get('longitude') + ? { + latitude: Number(searchParams.get('latitude')), + longitude: Number(searchParams.get('longitude')), + } + : null +} + export function ExpenseForm({ group, expense, @@ -184,6 +196,7 @@ export function ExpenseForm({ isReimbursement: expense.isReimbursement, documents: expense.documents, notes: expense.notes ?? '', + location: expense.location, } : searchParams.get('reimbursement') ? { @@ -207,6 +220,7 @@ export function ExpenseForm({ saveDefaultSplittingOptions: false, documents: [], notes: '', + location: getLocationFromSearchParams(searchParams), } : { title: searchParams.get('title') ?? '', @@ -234,6 +248,7 @@ export function ExpenseForm({ ] : [], notes: '', + location: getLocationFromSearchParams(searchParams), }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) @@ -698,6 +713,31 @@ export function ExpenseForm({ + + + + Location + + Where was the {sExpense} made? + + + { + return ( + + + + ) + }} + /> + + + {runtimeFeatureFlags.enableExpenseDocuments && ( diff --git a/src/components/expense-location-input.tsx b/src/components/expense-location-input.tsx new file mode 100644 index 00000000..4145aba2 --- /dev/null +++ b/src/components/expense-location-input.tsx @@ -0,0 +1,68 @@ +import { useToast } from '@/components/ui/use-toast' +import { ExpenseFormValues } from '@/lib/schemas' +import { LocateFixed, MapPinOff } from 'lucide-react' +import { AsyncButton } from './async-button' +import { Map } from './map' +import { Button } from './ui/button' + +type Props = { + location: ExpenseFormValues['location'] + updateLocation: (location: ExpenseFormValues['location']) => void +} + +export function ExpenseLocationInput({ location, updateLocation }: Props) { + const { toast } = useToast() + + async function getCoordinates(): Promise { + return new Promise(function (resolve, reject) { + navigator.geolocation.getCurrentPosition(resolve, reject) + }) + } + + async function setCoordinates(): Promise { + try { + const { latitude, longitude } = (await getCoordinates()).coords + updateLocation({ latitude, longitude }) + } catch (error) { + console.error(error) + toast({ + title: 'Error while determining location', + description: + 'Something wrong happened when determining your current location. Please approve potential authorisation dialogues or try again later.', + variant: 'destructive', + }) + } + } + + function unsetCoordinates() { + updateLocation(null) + } + + return ( + <> + +
+ + + Locate me + + {location && ( + + )} +
+ + ) +} diff --git a/src/components/map/index.ts b/src/components/map/index.ts new file mode 100644 index 00000000..39a3cb7b --- /dev/null +++ b/src/components/map/index.ts @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +// This is necessary for using react-leaflet with Next.js +// see: https://github.com/PaulLeCam/react-leaflet/issues/956#issuecomment-1057881284 +const Map = dynamic(() => import('./map-component'), { + ssr: false, +}) + +export { Map } diff --git a/src/components/map/map-component.tsx b/src/components/map/map-component.tsx new file mode 100644 index 00000000..757e75cf --- /dev/null +++ b/src/components/map/map-component.tsx @@ -0,0 +1,67 @@ +import { ExpenseFormValues } from '@/lib/schemas' +import 'leaflet-defaulticon-compatibility' +import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css' +import 'leaflet/dist/leaflet.css' +import React, { useState } from 'react' +import { MapContainer, TileLayer } from 'react-leaflet' +import MapSection from './map-section' +import './map-styles.css' +import LocationMarker from './marker' +import { MapSectionType } from './types' + +const noLocationMapSection: MapSectionType = { + location: { latitude: 0, longitude: 0 }, + zoom: 1, +} + +const DEFAULT_ZOOM_ON_LOCATION = 13 + +type MapProps = { + location: ExpenseFormValues['location'] + updateLocation: (location: ExpenseFormValues['location']) => void +} + +const Map: React.FC = ({ location, updateLocation }) => { + const [marker, setMarker] = useState(location) + const [mapSection, setMapSection] = useState( + location + ? { location, zoom: DEFAULT_ZOOM_ON_LOCATION } + : noLocationMapSection, + ) + + const isExpenseLocationDivergedFromMarker = ( + location: ExpenseFormValues['location'], + marker: ExpenseFormValues['location'], + ): boolean => + location?.latitude !== marker?.latitude || + location?.longitude !== marker?.longitude + + if (isExpenseLocationDivergedFromMarker(location, marker)) { + setMarker(location) + if (location) { + setMapSection({ location, zoom: DEFAULT_ZOOM_ON_LOCATION }) + } else { + setMapSection(noLocationMapSection) + } + } + + function onMarkerMoved( + location: Exclude, + ) { + setMarker(location) + updateLocation(location) + } + + return ( + + + + + + ) +} + +export default Map diff --git a/src/components/map/map-section.tsx b/src/components/map/map-section.tsx new file mode 100644 index 00000000..35a1ac3d --- /dev/null +++ b/src/components/map/map-section.tsx @@ -0,0 +1,34 @@ +import { useMap, useMapEvents } from 'react-leaflet' +import { MapSectionType } from './types' + +type MapSectionProps = { + section: MapSectionType + updateSection: (section: MapSectionType) => void +} + +function MapSection({ section, updateSection }: MapSectionProps) { + const map = useMap() + + useMapEvents({ + dragend() { + const { lat: latitude, lng: longitude } = map.getCenter() + const location = { latitude, longitude } + const zoom = map.getZoom() + updateSection({ location, zoom }) + }, + zoomend() { + const { lat: latitude, lng: longitude } = map.getCenter() + const location = { latitude, longitude } + const zoom = map.getZoom() + updateSection({ location, zoom }) + }, + }) + + map.setView( + [section.location.latitude, section.location.longitude], + section.zoom, + ) + return null +} + +export default MapSection diff --git a/src/components/map/map-styles.css b/src/components/map/map-styles.css new file mode 100644 index 00000000..e3361083 --- /dev/null +++ b/src/components/map/map-styles.css @@ -0,0 +1,10 @@ +.dark { + .map-tiles { + filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) + saturate(0.3) brightness(0.7); + } + + .leaflet-marker-shadow { + display: none; + } +} diff --git a/src/components/map/marker.tsx b/src/components/map/marker.tsx new file mode 100644 index 00000000..3514b692 --- /dev/null +++ b/src/components/map/marker.tsx @@ -0,0 +1,36 @@ +import { ExpenseFormValues } from '@/lib/schemas' +import { LeafletEventHandlerFnMap } from 'leaflet' +import { Marker, useMapEvents } from 'react-leaflet' + +type MarkerProps = { + location: ExpenseFormValues['location'] + updateLocation: (location: { latitude: number; longitude: number }) => void +} + +function LocationMarker({ location, updateLocation }: MarkerProps) { + useMapEvents({ + click(event) { + const { lat: latitude, lng: longitude } = event.latlng + updateLocation({ latitude, longitude }) + }, + }) + + const markerEventHandlers: LeafletEventHandlerFnMap = { + moveend(event) { + const { lat: latitude, lng: longitude } = event.target.getLatLng() + updateLocation({ latitude, longitude }) + }, + } + + return ( + location && ( + + ) + ) +} + +export default LocationMarker diff --git a/src/components/map/types.ts b/src/components/map/types.ts new file mode 100644 index 00000000..4745f9f8 --- /dev/null +++ b/src/components/map/types.ts @@ -0,0 +1,4 @@ +export type MapSectionType = { + location: { latitude: number; longitude: number } + zoom: number +} diff --git a/src/lib/api.ts b/src/lib/api.ts index a592ada4..37d1604c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -79,6 +79,11 @@ export async function createExpense( }, }, notes: expenseFormValues.notes, + location: { + ...(expenseFormValues.location && { + create: { ...expenseFormValues.location }, + }), + }, }, }) } @@ -208,6 +213,15 @@ export async function updateExpense( })), }, notes: expenseFormValues.notes, + location: { + delete: !!existingExpense.location && !expenseFormValues.location, + ...(expenseFormValues.location && { + upsert: { + create: { ...expenseFormValues.location }, + update: { ...expenseFormValues.location }, + }, + }), + }, }, }) } @@ -299,7 +313,13 @@ export async function getGroupExpenseCount(groupId: string) { export async function getExpense(groupId: string, expenseId: string) { return prisma.expense.findUnique({ where: { id: expenseId }, - include: { paidBy: true, paidFor: true, category: true, documents: true }, + include: { + paidBy: true, + paidFor: true, + category: true, + documents: true, + location: true, + }, }) } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 9578b025..bb43adad 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -118,6 +118,12 @@ export const expenseFormSchema = z ) .default([]), notes: z.string().optional(), + location: z + .object({ + latitude: z.number().refine((val) => val > -90 && val < 90), + longitude: z.number().refine((val) => val > -180 && val < 180), + }) + .nullable(), }) .superRefine((expense, ctx) => { let sum = 0