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