diff --git a/.github/workflows/readme-screenshots.yml b/.github/workflows/readme-screenshots.yml
index 8820057..b5909d0 100644
--- a/.github/workflows/readme-screenshots.yml
+++ b/.github/workflows/readme-screenshots.yml
@@ -49,7 +49,7 @@ jobs:
run: npm run build
- name: Generate screenshots
- run: npm run screenshots
+ run: npm run screenshots:ci
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
diff --git a/README.md b/README.md
index 421048e..5a060df 100644
--- a/README.md
+++ b/README.md
@@ -66,8 +66,8 @@ Create DB Migrations (for production)
### Open ToDos
- Automatisches abmelden aller Accounts nach update (via changes)
- Android Zahlentastatur Komma ausgeblendet
+- Kostenanpassung bei Gruppenbestellung greift für alle der gleichen Kategorien
#### Nice Improvements:
-- Item-Page: Item-Reihenfolge anpassbar machen (persistent)
- Item-Page: Kategorie-filter für user persistieren
- Gruppen-Split
- Verrechnungskonten-Auszahlung für Admins auch selbst möglich
diff --git a/package-lock.json b/package-lock.json
index 77fce2a..8fc231b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4933,6 +4933,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7034,6 +7035,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
diff --git a/package.json b/package.json
index a19533a..5aa211b 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,14 @@
"lint": "eslint .",
"start": "next start",
"test": "vitest",
- "screenshots": "npm run screenshots:seed && npm run screenshots:capture",
- "screenshots:seed": "jiti scripts/screenshots/seed.ts",
- "screenshots:capture": "jiti scripts/screenshots/capture.ts",
+ "screenshots": "jiti scripts/screenshots/capture.ts",
+ "screenshots:ci": "npm run db:seed && npm run screenshots:capture",
"db:generate": "prisma generate",
"db:migrate": "npx prisma migrate dev",
+ "db:migrate:prod": "npx prisma migrate deploy",
"db:push": "npx prisma db push",
- "db:studio": "npx prisma studio"
+ "db:studio": "npx prisma studio",
+ "db:seed": "jiti scripts/screenshots/seed.ts"
},
"dependencies": {
"@headlessui/react": "^2.0.3",
@@ -83,4 +84,4 @@
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
-}
+}
\ No newline at end of file
diff --git a/prisma/migrations/20260507120000_add_canonical_item_id/migration.sql b/prisma/migrations/20260507120000_add_canonical_item_id/migration.sql
new file mode 100644
index 0000000..7674d51
--- /dev/null
+++ b/prisma/migrations/20260507120000_add_canonical_item_id/migration.sql
@@ -0,0 +1,48 @@
+-- Add canonical item IDs to preserve logical item identity across immutable item copies
+-- Rebuild the tables directly so the migration also recovers from a failed
+-- earlier attempt that already added nullable canonicalItemId columns.
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+
+DROP TABLE IF EXISTS "new_Item";
+DROP TABLE IF EXISTS "new_ItemCategoryMapping";
+
+CREATE TABLE "new_Item" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "canonicalItemId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "price" REAL NOT NULL,
+ "is_active" BOOLEAN NOT NULL DEFAULT true,
+ "for_grouporders" BOOLEAN NOT NULL DEFAULT false,
+ "accountId" TEXT NOT NULL,
+ CONSTRAINT "Item_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "ClearingAccount" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+INSERT INTO "new_Item" ("id", "canonicalItemId", "name", "price", "is_active", "for_grouporders", "accountId")
+SELECT "id", "id", "name", "price", "is_active", "for_grouporders", "accountId"
+FROM "Item";
+DROP TABLE "Item";
+ALTER TABLE "new_Item" RENAME TO "Item";
+CREATE INDEX IF NOT EXISTS "Item_canonicalItemId_idx" ON "Item"("canonicalItemId");
+
+CREATE TABLE "new_ItemCategoryMapping" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "canonicalItemId" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "transactionId" TEXT NOT NULL,
+ CONSTRAINT "ItemCategoryMapping_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "ItemCategoryMapping_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+INSERT INTO "new_ItemCategoryMapping" ("id", "canonicalItemId", "itemId", "transactionId")
+SELECT
+ "ItemCategoryMapping"."id",
+ (SELECT "Item"."canonicalItemId" FROM "Item" WHERE "Item"."id" = "ItemCategoryMapping"."itemId"),
+ "ItemCategoryMapping"."itemId",
+ "ItemCategoryMapping"."transactionId"
+FROM "ItemCategoryMapping";
+DROP TABLE "ItemCategoryMapping";
+ALTER TABLE "new_ItemCategoryMapping" RENAME TO "ItemCategoryMapping";
+CREATE INDEX IF NOT EXISTS "ItemCategoryMapping_canonicalItemId_idx" ON "ItemCategoryMapping"("canonicalItemId");
+
+PRAGMA foreign_key_check;
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7b1ced8..93e15c6 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -21,6 +21,7 @@ model Item {
// items must be immutable and shouldn't be deleted (if transactions exists)
// - create copy instead and set is_active=false
id String @id @default(cuid())
+ canonicalItemId String
name String
categories Category[]
price Float
@@ -29,6 +30,7 @@ model Item {
is_active Boolean @default(true)
for_grouporders Boolean @default(false)
accountId String
+ @@index([canonicalItemId])
}
model ProcurementItem {
@@ -107,11 +109,13 @@ model ClearingAccount {
model ItemCategoryMapping {
id String @id @default(cuid())
item Item @relation(fields: [itemId], references: [id])
+ canonicalItemId String
categories Category[]
Transaction Transaction @relation(fields: [transactionId], references: [id])
itemId String
transactionId String
+ @@index([canonicalItemId])
}
model ProcurementItemBilling {
diff --git a/scripts/screenshots/seed.ts b/scripts/screenshots/seed.ts
index 30a2fcc..ae8d58c 100644
--- a/scripts/screenshots/seed.ts
+++ b/scripts/screenshots/seed.ts
@@ -150,6 +150,7 @@ async function seed() {
await prisma.item.create({
data: {
id: item.id,
+ canonicalItemId: item.id,
name: item.name,
price: item.price,
for_grouporders: item.for_grouporders ?? false,
@@ -246,6 +247,7 @@ async function seed() {
items: {
create: {
item: { connect: { id: ids.items[0] } },
+ canonicalItemId: ids.items[0],
categories: { connect: [{ id: ids.categories[0] }] },
},
},
@@ -282,6 +284,7 @@ async function seed() {
items: {
create: {
item: { connect: { id: ids.items[1] } },
+ canonicalItemId: ids.items[1],
categories: { connect: [{ id: ids.categories[1] }] },
},
},
diff --git a/src/components/General/Balance.tsx b/src/components/General/Balance.tsx
index 9f8b229..a5daa0d 100644
--- a/src/components/General/Balance.tsx
+++ b/src/components/General/Balance.tsx
@@ -1,15 +1,29 @@
+import type { HTMLProps } from "react"
+
type Props = {
- balance?: number
+ balance?: number,
+ allowOverdraw?: boolean
}
export const Balance = (props: Props) => {
if (props.balance === undefined) {
return
} else {
- let color = ""
- if (props.balance > 0) {
- color = "text-green-600"
- } else if (props.balance < 0) {
- color = "text-red-700"
+ let color: HTMLProps["className"] = ""
+ if (props.allowOverdraw) {
+ if (props.balance > 0) {
+ color = "text-gray-300"
+ } else if (props.balance < -150) {
+ color = "text-amber-700"
+ } else if (props.balance < 0) {
+ color = "text-blue-grey-600"
+ }
+ }
+ else {
+ if (props.balance > 0) {
+ color = "text-green-600"
+ } else if (props.balance < 0) {
+ color = "text-red-700"
+ }
}
return {props.balance.toFixed(2)}€
}
diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx
index 432009b..5bb7258 100644
--- a/src/components/Layout/Header.tsx
+++ b/src/components/Layout/Header.tsx
@@ -218,7 +218,7 @@ export default function Header() {
-
+
diff --git a/src/components/PageComponents/UserOverview.tsx b/src/components/PageComponents/UserOverview.tsx
index ab581d1..8454899 100644
--- a/src/components/PageComponents/UserOverview.tsx
+++ b/src/components/PageComponents/UserOverview.tsx
@@ -109,7 +109,7 @@ const UserOverview = () => {
-
+
|
{user.allowOverdraw ? (
diff --git a/src/pages/buy.tsx b/src/pages/buy.tsx
index dfb3345..b1abf1f 100644
--- a/src/pages/buy.tsx
+++ b/src/pages/buy.tsx
@@ -1,5 +1,5 @@
import { type NextPage } from "next"
-import { useRef, useState } from "react"
+import { useRef, useState, useSyncExternalStore } from "react"
import { Search, Package } from "lucide-react"
import ActionResponsePopup, {
type AnimationHandle,
@@ -9,6 +9,35 @@ import BuyItemCard from "~/components/General/BuyItemCard"
import RegularPage from "~/components/Layout/RegularPage"
import { api } from "~/utils/api"
+type SortMode = "recent" | "alphabetic" | "mostBought"
+
+const SORT_MODE_STORAGE_KEY = "buyPageSortMode"
+const SORT_MODE_STORAGE_EVENT = "buyPageSortModeChange"
+const sortModes: SortMode[] = ["recent", "alphabetic", "mostBought"]
+
+const isSortMode = (value: string | null): value is SortMode => {
+ return sortModes.includes(value as SortMode)
+}
+
+const getStoredSortMode = (): SortMode => {
+ if (typeof window === "undefined") {
+ return "recent"
+ }
+
+ const storedSortMode = window.localStorage.getItem(SORT_MODE_STORAGE_KEY)
+ return isSortMode(storedSortMode) ? storedSortMode : "recent"
+}
+
+const subscribeToSortMode = (onStoreChange: () => void) => {
+ window.addEventListener("storage", onStoreChange)
+ window.addEventListener(SORT_MODE_STORAGE_EVENT, onStoreChange)
+
+ return () => {
+ window.removeEventListener("storage", onStoreChange)
+ window.removeEventListener(SORT_MODE_STORAGE_EVENT, onStoreChange)
+ }
+}
+
const BuyPage: NextPage = () => {
const allItemsRequest = api.item.getBuyable.useQuery()
const allCategoriesRequest = api.category.getAllWithItems.useQuery()
@@ -16,19 +45,26 @@ const BuyPage: NextPage = () => {
const trpcUtils = api.useUtils()
const animationRef = useRef(null)
const [searchString, setSearchString] = useState("")
+ const sortMode = useSyncExternalStore(subscribeToSortMode, getStoredSortMode, () => "recent")
const [categoryOverrides, setCategoryOverrides] = useState<{ [index: string]: boolean }>({})
const apiBuyOneItemMultiple = api.item.buyItem.useMutation()
+ const handleSortModeChange = (newSortMode: SortMode) => {
+ window.localStorage.setItem(SORT_MODE_STORAGE_KEY, newSortMode)
+ window.dispatchEvent(new Event(SORT_MODE_STORAGE_EVENT))
+ }
+
const buyAction = async (itemID: string, quantity: number = 1): Promise => {
try {
await apiBuyOneItemMultiple.mutateAsync({ productID: itemID, quantity })
-
- const message = quantity === 1
- ? "Erfolgreich gekauft!"
+
+ const message = quantity === 1
+ ? "Erfolgreich gekauft!"
: `${quantity}x erfolgreich gekauft!`
animate(animationRef, "success", message)
await trpcUtils.user.invalidate()
+ await trpcUtils.item.getBuyable.invalidate()
} catch (error: any) {
console.error(error)
animate(animationRef, "failure", error.message)
@@ -59,7 +95,27 @@ const BuyPage: NextPage = () => {
const searchShown = item.name.toLowerCase().includes(searchString.toLowerCase())
return categoryShown && searchShown
})
- .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
+ .sort((a, b) => {
+ const nameComparison = a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+
+ if (sortMode === "alphabetic") {
+ return nameComparison
+ }
+ else if (sortMode === "mostBought") {
+ if (a.userOrderCount !== b.userOrderCount) {
+ return b.userOrderCount - a.userOrderCount
+ }
+ return nameComparison
+ }
+ else {
+ const aLastBoughtAt = a.userLastBoughtAt?.getTime() ?? 0
+ const bLastBoughtAt = b.userLastBoughtAt?.getTime() ?? 0
+ if (aLastBoughtAt !== bLastBoughtAt) {
+ return bLastBoughtAt - aLastBoughtAt
+ }
+ return nameComparison
+ }
+ })
const selectedCategoriesCount = Object.values(displayCategories).filter(Boolean).length
const allCategoriesSelected = selectedCategoriesCount === allRelevantCategories?.length
@@ -91,13 +147,14 @@ const BuyPage: NextPage = () => {
{/* Search and Filter Section */}
- {/* Search Bar */}
-
+
+
- {/* Results Summary */}
-
- {displayedItems ? (
-
- {displayedItems.length} Produkt{displayedItems.length !== 1 ? "e" : ""} gefunden
-
- ) : (
- Lade...
- )}
+
+
+
+
+
+
+
+
+
+
+
+ {displayedItems ? (
+
+ {displayedItems.length} Produkt{displayedItems.length !== 1 ? "e" : ""} gefunden
+
+ ) : (
+ Lade...
+ )}
+
@@ -136,7 +213,7 @@ const BuyPage: NextPage = () => {
-
+
-
+
{allRelevantCategories?.filter(i => i.is_active).map((category) => {
const isSelected = displayCategories[category.id] === true
const itemCount = category.items.filter(item => !item.for_grouporders && item.is_active).length
-
+
return (
|