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 (