diff --git a/src/database/prisma/migrations/20251007172230_sexet_stocklist/migration.sql b/src/database/prisma/migrations/20251007172230_sexet_stocklist/migration.sql new file mode 100644 index 000000000..39d65efdb --- /dev/null +++ b/src/database/prisma/migrations/20251007172230_sexet_stocklist/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "DrinkQuantityType" AS ENUM ('NONE', 'WEIGHT', 'COUNTS'); + +-- CreateEnum +CREATE TYPE "DrinkGroup" AS ENUM ('S1', 'S2', 'S3', 'S4'); + +-- CreateTable +CREATE TABLE "drinkitem" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "quantity_type" "DrinkQuantityType" NOT NULL, + "name" TEXT NOT NULL, + "price" INTEGER NOT NULL, + "group" "DrinkGroup" NOT NULL, + "systembolaget_id" INTEGER NOT NULL, + + CONSTRAINT "drinkitem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "drinkitembatch" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "drink_item_id" UUID NOT NULL, + "best_before_date" TIMESTAMP(3) NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "drinkitembatch_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "drinkitembatch" ADD CONSTRAINT "drinkitembatch_drink_item_id_fkey" FOREIGN KEY ("drink_item_id") REFERENCES "drinkitem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/database/prisma/migrations/20251103141206_stocklist_update/migration.sql b/src/database/prisma/migrations/20251103141206_stocklist_update/migration.sql new file mode 100644 index 000000000..f23cccad9 --- /dev/null +++ b/src/database/prisma/migrations/20251103141206_stocklist_update/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `best_before_date` on the `drinkitembatch` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "drinkitembatch" DROP COLUMN "best_before_date"; diff --git a/src/database/prisma/migrations/20251124165427_/migration.sql b/src/database/prisma/migrations/20251124165427_/migration.sql new file mode 100644 index 000000000..821100078 --- /dev/null +++ b/src/database/prisma/migrations/20251124165427_/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "drinkitem" ADD COLUMN "bottle_empty_weight" INTEGER, +ADD COLUMN "bottle_full_weight" INTEGER; diff --git a/src/database/prisma/migrations/20251124193841_/migration.sql b/src/database/prisma/migrations/20251124193841_/migration.sql new file mode 100644 index 000000000..b9ec7873b --- /dev/null +++ b/src/database/prisma/migrations/20251124193841_/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "sexetinventoryvaluelog" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "date" TIMESTAMP(3) NOT NULL, + "value" INTEGER NOT NULL, + + CONSTRAINT "sexetinventoryvaluelog_pkey" PRIMARY KEY ("id") +); diff --git a/src/database/prisma/migrations/20251124204050_/migration.sql b/src/database/prisma/migrations/20251124204050_/migration.sql new file mode 100644 index 000000000..6aba6d7a7 --- /dev/null +++ b/src/database/prisma/migrations/20251124204050_/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - Added the required column `date` to the `drinkitembatch` table without a default value. This is not possible if the table is not empty. + - Added the required column `user` to the `drinkitembatch` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "drinkitembatch" ADD COLUMN "date" TIMESTAMP(3) NOT NULL, +ADD COLUMN "user" TEXT NOT NULL; diff --git a/src/database/prisma/migrations/20251124204155_/migration.sql b/src/database/prisma/migrations/20251124204155_/migration.sql new file mode 100644 index 000000000..702aa9dbd --- /dev/null +++ b/src/database/prisma/migrations/20251124204155_/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `quantity` on the `drinkitembatch` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "drinkitembatch" DROP COLUMN "quantity", +ADD COLUMN "quantityIn" INTEGER, +ADD COLUMN "quantityOut" INTEGER; diff --git a/src/database/prisma/migrations/20251215180520_/migration.sql b/src/database/prisma/migrations/20251215180520_/migration.sql new file mode 100644 index 000000000..5c5c8cef3 --- /dev/null +++ b/src/database/prisma/migrations/20251215180520_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "drinkitembatch" ADD COLUMN "nrBottles" INTEGER; diff --git a/src/database/prisma/schema.prisma b/src/database/prisma/schema.prisma index 8cac61470..bb7cceec9 100644 --- a/src/database/prisma/schema.prisma +++ b/src/database/prisma/schema.prisma @@ -27,6 +27,19 @@ enum recurringType { YEARLY } +enum DrinkQuantityType { + NONE + WEIGHT + COUNTS +} + +enum DrinkGroup { + S1 + S2 + S3 + S4 +} + enum DocumentType { POLICY GUIDELINE @@ -444,6 +457,41 @@ model Markdown { @@map("markdowns") } +model DrinkItem { + id String @id() @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + quantityType DrinkQuantityType @map("quantity_type") + name String + price Int + group DrinkGroup + systembolagetID Int @map("systembolaget_id") + bottleEmptyWeight Int? @map("bottle_empty_weight") + bottleFullWeight Int? @map("bottle_full_weight") + stockLists DrinkItemBatch[] + + @@map("drinkitem") +} + +model DrinkItemBatch { + id String @id() @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + drinkItemId String @map("drink_item_id") @db.Uuid() + item DrinkItem @relation(fields: [drinkItemId], references: [id]) + quantityIn Int? + quantityOut Int? + user String + date DateTime + nrBottles Int? + + @@map("drinkitembatch") +} + +model SexetInventoryValueLog { + id String @id() @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + date DateTime + value Int + + @@map("sexetinventoryvaluelog") +} + model Meeting { id String @id() @default(dbgenerated("gen_random_uuid()")) @db.Uuid() title String @db.VarChar(255) diff --git a/src/database/schema.zmodel b/src/database/schema.zmodel index a4e97226d..00ce52e32 100644 --- a/src/database/schema.zmodel +++ b/src/database/schema.zmodel @@ -539,6 +539,68 @@ model Markdown { @@map("markdowns") } + +model DrinkItem { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + quantityType DrinkQuantityType @map("quantity_type") + name String + price Int + group DrinkGroup + systembolagetID Int @map("systembolaget_id") + bottleEmptyWeight Int? @map("bottle_empty_weight") + bottleFullWeight Int? @map("bottle_full_weight") + + stockLists DrinkItemBatch[] + + @@allow("read", true) + @@allow("create", has(auth().policies, "drinkitem:create")) + @@allow("update", has(auth().policies, "drinkitem:update")) + @@allow("delete", has(auth().policies, "drinkitem:delete")) + @@map("drinkitem") +} + +model DrinkItemBatch { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + drinkItemId String @map("drink_item_id") @db.Uuid + item DrinkItem @relation(fields: [drinkItemId], references: [id]) + quantityIn Int? + quantityOut Int? + user String + date DateTime + nrBottles Int? + + @@allow("read", true) + @@allow("create", has(auth().policies, "drinkitembatch:create")) + @@allow("update", has(auth().policies, "drinkitembatch:update")) + @@allow("delete", has(auth().policies, "drinkitembatch:delete")) + @@map("drinkitembatch") +} + +model SexetInventoryValueLog { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + date DateTime + value Int + + @@allow("read", true) + @@allow("create", has(auth().policies, "drinkitembatch:create")) + @@allow("update", has(auth().policies, "drinkitembatch:update")) + @@allow("delete", has(auth().policies, "drinkitembatch:delete")) + @@map("sexetinventoryvaluelog") +} + +enum DrinkQuantityType { + NONE + WEIGHT + COUNTS +} + +enum DrinkGroup { + S1 + S2 + S3 + S4 +} + model Meeting { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid title String @db.VarChar(255) diff --git a/src/database/seed/.snaplet/dataModel.json b/src/database/seed/.snaplet/dataModel.json index 83c782f73..4f46f415d 100644 --- a/src/database/seed/.snaplet/dataModel.json +++ b/src/database/seed/.snaplet/dataModel.json @@ -3881,6 +3881,280 @@ } ] }, + "drinkitem": { + "id": "public.drinkitem", + "schemaName": "public", + "tableName": "drinkitem", + "fields": [ + { + "id": "public.drinkitem.id", + "name": "id", + "columnName": "id", + "type": "uuid", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": true, + "isId": true, + "maxLength": null + }, + { + "id": "public.drinkitem.quantity_type", + "name": "quantity_type", + "columnName": "quantity_type", + "type": "DrinkQuantityType", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.name", + "name": "name", + "columnName": "name", + "type": "text", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.price", + "name": "price", + "columnName": "price", + "type": "int4", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.group", + "name": "group", + "columnName": "group", + "type": "DrinkGroup", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.systembolaget_id", + "name": "systembolaget_id", + "columnName": "systembolaget_id", + "type": "int4", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.bottle_empty_weight", + "name": "bottle_empty_weight", + "columnName": "bottle_empty_weight", + "type": "int4", + "isRequired": false, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitem.bottle_full_weight", + "name": "bottle_full_weight", + "columnName": "bottle_full_weight", + "type": "int4", + "isRequired": false, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "name": "drinkitembatch", + "type": "drinkitembatch", + "isRequired": false, + "kind": "object", + "relationName": "drinkitembatchTodrinkitem", + "relationFromFields": [], + "relationToFields": [], + "isList": true, + "isId": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false + } + ], + "uniqueConstraints": [ + { + "name": "drinkitem_pkey", + "fields": [ + "id" + ], + "nullNotDistinct": false + } + ] + }, + "drinkitembatch": { + "id": "public.drinkitembatch", + "schemaName": "public", + "tableName": "drinkitembatch", + "fields": [ + { + "id": "public.drinkitembatch.id", + "name": "id", + "columnName": "id", + "type": "uuid", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": true, + "isId": true, + "maxLength": null + }, + { + "id": "public.drinkitembatch.drink_item_id", + "name": "drink_item_id", + "columnName": "drink_item_id", + "type": "uuid", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitembatch.date", + "name": "date", + "columnName": "date", + "type": "timestamp", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitembatch.user", + "name": "user", + "columnName": "user", + "type": "text", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitembatch.quantityIn", + "name": "quantityIn", + "columnName": "quantityIn", + "type": "int4", + "isRequired": false, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitembatch.quantityOut", + "name": "quantityOut", + "columnName": "quantityOut", + "type": "int4", + "isRequired": false, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.drinkitembatch.nrBottles", + "name": "nrBottles", + "columnName": "nrBottles", + "type": "int4", + "isRequired": false, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "name": "drinkitem", + "type": "drinkitem", + "isRequired": true, + "kind": "object", + "relationName": "drinkitembatchTodrinkitem", + "relationFromFields": [ + "drink_item_id" + ], + "relationToFields": [ + "id" + ], + "isList": false, + "isId": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false + } + ], + "uniqueConstraints": [ + { + "name": "drinkitembatch_pkey", + "fields": [ + "id" + ], + "nullNotDistinct": false + } + ] + }, "elections": { "id": "public.elections", "schemaName": "public", @@ -8573,6 +8847,64 @@ } ] }, + "sexetinventoryvaluelog": { + "id": "public.sexetinventoryvaluelog", + "schemaName": "public", + "tableName": "sexetinventoryvaluelog", + "fields": [ + { + "id": "public.sexetinventoryvaluelog.id", + "name": "id", + "columnName": "id", + "type": "uuid", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": true, + "isId": true, + "maxLength": null + }, + { + "id": "public.sexetinventoryvaluelog.date", + "name": "date", + "columnName": "date", + "type": "timestamp", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + }, + { + "id": "public.sexetinventoryvaluelog.value", + "name": "value", + "columnName": "value", + "type": "int4", + "isRequired": true, + "kind": "scalar", + "isList": false, + "isGenerated": false, + "sequence": false, + "hasDefaultValue": false, + "isId": false, + "maxLength": null + } + ], + "uniqueConstraints": [ + { + "name": "sexetinventoryvaluelog_pkey", + "fields": [ + "id" + ], + "nullNotDistinct": false + } + ] + }, "shoppable": { "id": "public.shoppable", "schemaName": "public", @@ -9857,6 +10189,37 @@ } ] }, + "DrinkGroup": { + "schemaName": "public", + "values": [ + { + "name": "S1" + }, + { + "name": "S2" + }, + { + "name": "S3" + }, + { + "name": "S4" + } + ] + }, + "DrinkQuantityType": { + "schemaName": "public", + "values": [ + { + "name": "COUNTS" + }, + { + "name": "NONE" + }, + { + "name": "WEIGHT" + } + ] + }, "ShoppableType": { "schemaName": "public", "values": [ diff --git a/src/lib/utils/apiNames.ts b/src/lib/utils/apiNames.ts index 343efd43e..eb20c8f5d 100644 --- a/src/lib/utils/apiNames.ts +++ b/src/lib/utils/apiNames.ts @@ -21,6 +21,12 @@ const apiNames = { TAGS: { ...crud("tags"), }, + DRINKITEM: { + ...crud("drinkitem"), + }, + DRINKITEMBATCH: { + ...crud("drinkitembatch"), + }, EVENT: { ...crud("event"), COMMENT: "event:comment", diff --git a/src/lib/utils/getTotalInventoryValue.ts b/src/lib/utils/getTotalInventoryValue.ts new file mode 100644 index 000000000..1c9c4621c --- /dev/null +++ b/src/lib/utils/getTotalInventoryValue.ts @@ -0,0 +1,53 @@ +import { DrinkQuantityType } from "@prisma/client"; +import type { Prisma, PrismaClient } from "@prisma/client"; + +export async function getTotalInventoryValue( + prisma: PrismaClient | Prisma.TransactionClient, +) { + const items = await prisma.drinkItemBatch.findMany({ + include: { item: true }, + }); + + const groupedMap = new Map(); + + for (const batch of items) { + const existing = groupedMap.get(batch.item.id); + if (existing) { + existing.quantityIn = + (existing.quantityIn ?? 0) + (batch.quantityIn ?? 0); + existing.quantityOut = + (existing.quantityOut ?? 0) + (batch.quantityOut ?? 0); + + if (batch.item.quantityType === DrinkQuantityType.WEIGHT) { + const isBatchIn = batch.quantityIn != null; + if (isBatchIn) { + existing.nrBottles = existing.nrBottles! + batch.nrBottles!; + } else { + existing.nrBottles = existing.nrBottles! - batch.nrBottles!; + } + } + } else { + groupedMap.set(batch.item.id, { ...batch }); + } + } + + const grouped = Array.from(groupedMap.values()); + + const totalInventoryValue = grouped.reduce((sum, i) => { + if (i.item.quantityType === DrinkQuantityType.WEIGHT) { + const realWeight = + (i.quantityIn ?? 0) - + (i.quantityOut ?? 0) - + i.item.bottleEmptyWeight! * i.nrBottles!; + const fullRealWeight = + i.item.bottleFullWeight! - i.item.bottleEmptyWeight!; + const pricePerWeight = i.item.price / fullRealWeight; + const price = pricePerWeight * realWeight; + return Math.floor(sum + price); + } + + return sum + i.item.price * ((i.quantityIn ?? 0) - (i.quantityOut ?? 0)); + }, 0); + + return { totalInventoryValue, grouped }; +} diff --git a/src/routes/(app)/admin/stocklist/+page.server.ts b/src/routes/(app)/admin/stocklist/+page.server.ts new file mode 100644 index 000000000..f7dfdcaaf --- /dev/null +++ b/src/routes/(app)/admin/stocklist/+page.server.ts @@ -0,0 +1,11 @@ +import { DrinkQuantityType } from "@prisma/client"; +import type { PageServerLoad } from "./$types"; +import { getTotalInventoryValue } from "$lib/utils/getTotalInventoryValue"; + +export const load: PageServerLoad = async ({ locals }) => { + const { prisma, user } = locals; + + const { totalInventoryValue, grouped } = await getTotalInventoryValue(prisma); + + return { totalInventoryValue, grouped }; +}; diff --git a/src/routes/(app)/admin/stocklist/+page.svelte b/src/routes/(app)/admin/stocklist/+page.svelte new file mode 100644 index 000000000..8f567050c --- /dev/null +++ b/src/routes/(app)/admin/stocklist/+page.svelte @@ -0,0 +1,54 @@ + + + + + + +
+ + + + + + + + + + + + {#each data.grouped as item} + {#if item.item.quantityType === DrinkQuantityType.COUNTS} + + + + + + + + {:else} + + + + + + + + {/if} + {/each} + +
Id NamnPrisAntal/ViktGroup
{item.item.systembolagetID}{item.item.name}{item.item.price / 100} kr{(item.quantityIn ?? 0) - (item.quantityOut ?? 0)}{item.item.group}
{item.item.systembolagetID}{item.item.name}{item.item.price / 100} kr{(item.quantityIn ?? 0) - (item.quantityOut ?? 0)} g{item.item.group}
+
diff --git a/src/routes/(app)/admin/stocklist/StocklistNav.svelte b/src/routes/(app)/admin/stocklist/StocklistNav.svelte new file mode 100644 index 000000000..0fef3bf71 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/StocklistNav.svelte @@ -0,0 +1,46 @@ + + +
+
+ + {#if showDateInput && dateValue !== undefined && onDateChange} + onDateChange?.(e.currentTarget.value)} + /> + {/if} +
+ {#if totalInventoryValue !== undefined} +

+ Totalt lagervärde: {totalInventoryValue / 100} kr +

+ {/if} +
diff --git a/src/routes/(app)/admin/stocklist/addproduct/+page.server.ts b/src/routes/(app)/admin/stocklist/addproduct/+page.server.ts new file mode 100644 index 000000000..bb2ae11be --- /dev/null +++ b/src/routes/(app)/admin/stocklist/addproduct/+page.server.ts @@ -0,0 +1,50 @@ +import { DrinkQuantityType, DrinkGroup } from "@prisma/client"; +import type { Actions } from "@sveltejs/kit"; +import { superValidate, fail, message } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import { z } from "zod"; +import type { PageServerLoad } from "./$types"; +import apiNames from "$lib/utils/apiNames"; +import { authorize } from "$lib/utils/authorization"; + +const zDrinkGroup = z.nativeEnum(DrinkGroup); +const zDrinkQuantityType = z.nativeEnum(DrinkQuantityType); + +export const load: PageServerLoad = async (event) => { + const { prisma } = event.locals; + const form = await superValidate(event.request, zod(DrinkItemSchema)); + return { form }; +}; + +const DrinkItemSchema = z.object({ + quantityType: zDrinkQuantityType, + name: z.string().min(1), + price: z.number(), + group: zDrinkGroup, + systembolagetID: z.number().int(), + bottleEmptyWeight: z.number().int(), + bottleFullWeight: z.number().int(), +}); + +export const actions: Actions = { + createDrinkItem: async (event) => { + const { user, prisma } = event.locals; + authorize(apiNames.DRINKITEM.CREATE, user); + const form = await superValidate(event.request, zod(DrinkItemSchema)); + if (!form.valid) return fail(400, { form }); + + await prisma.drinkItem.create({ + data: { + quantityType: form.data.quantityType, + name: form.data.name, + price: form.data.price * 100, + group: form.data.group, + systembolagetID: form.data.systembolagetID, + bottleEmptyWeight: form.data.bottleEmptyWeight, + bottleFullWeight: form.data.bottleFullWeight, + }, + }); + + return message(form, { message: "Produkt tillagd" }); + }, +}; diff --git a/src/routes/(app)/admin/stocklist/addproduct/+page.svelte b/src/routes/(app)/admin/stocklist/addproduct/+page.svelte new file mode 100644 index 000000000..72b53e892 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/addproduct/+page.svelte @@ -0,0 +1,97 @@ + + + + +
+ +
+{#if $form.quantityType != DrinkQuantityType.NONE} +
+

+ {$form.quantityType == DrinkQuantityType.COUNTS + ? "Öl/Cider/Vin/Annat" + : "Sprit"} +

+
+ + + + + + + {#if $form.quantityType === DrinkQuantityType.WEIGHT} + + + {/if} +
+ +
+ +
+{/if} diff --git a/src/routes/(app)/admin/stocklist/showproducts/+page.server.ts b/src/routes/(app)/admin/stocklist/showproducts/+page.server.ts new file mode 100644 index 000000000..45c08fc36 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/showproducts/+page.server.ts @@ -0,0 +1,45 @@ +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "../$types"; +import { z } from "zod"; +import { fail, message, superValidate } from "sveltekit-superforms"; +import { Prisma } from "@prisma/client"; + +const deleteSchema = z.object({ + id: z.string(), +}); + +export const load: PageServerLoad = async ({ locals }) => { + const { prisma, user } = locals; + + const drinkItems = await prisma.drinkItem.findMany() + const deleteForm = await superValidate(zod(deleteSchema)); + + return { drinkItems, deleteForm }; +}; + + + +export const actions: Actions = { + deleteEntry: async (event) => { + const { prisma, user } = event.locals; + + const form = await superValidate(event.request, zod(deleteSchema)); + if (!form.valid) return fail(400, { form }); + + try { + await prisma.drinkItem.delete({ + where: { id: form.data.id }, + }); + } catch (error) { + return message(form, {message: `Produkt finns i lager`}) + + } + + + + + + return message(form, { message: `Produkt borttagen` }); + } + +} \ No newline at end of file diff --git a/src/routes/(app)/admin/stocklist/showproducts/+page.svelte b/src/routes/(app)/admin/stocklist/showproducts/+page.svelte new file mode 100644 index 000000000..3b41af6c0 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/showproducts/+page.svelte @@ -0,0 +1,76 @@ + + + +
+ + + + + + + + + + + + + + {#each data.drinkItems as item} + {#if item.quantityType === DrinkQuantityType.COUNTS} + + + + + + + + + + {:else} + + + + + + + + + + {/if} + {/each} + +
Id NamnPrisGruppVikt TomVikt FullTa bort
{item.systembolagetID}{item.name}{item.price / 100} kr{item.group}-- +
+ + +
+
{item.systembolagetID}{item.name}{item.price / 100} kr{item.group}{item.bottleEmptyWeight}{item.bottleFullWeight} +
+ + +
+
+
diff --git a/src/routes/(app)/admin/stocklist/stockchange/+page.server.ts b/src/routes/(app)/admin/stocklist/stockchange/+page.server.ts new file mode 100644 index 000000000..dd10f91a9 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/stockchange/+page.server.ts @@ -0,0 +1,109 @@ +import type { Actions } from "@sveltejs/kit"; +import { superValidate, fail, message } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import { z } from "zod"; +import type { PageServerLoad } from "./$types"; +import apiNames from "$lib/utils/apiNames"; +import { authorize } from "$lib/utils/authorization"; +import dayjs from "dayjs"; + +export const load: PageServerLoad = async (event) => { + const { prisma } = event.locals; + const form = await superValidate(event.request, zod(DrinkItemBatchSchema)); + const drinks = await prisma.drinkItem.findMany(); + + const entriesIn = await prisma.drinkItemBatch.findMany({ + where: { + quantityIn: { gt: 0 }, + }, + }); + const entriesOut = await prisma.drinkItemBatch.findMany({ + where: { + quantityOut: { gt: 0 }, + }, + }); + + return { form, drinks, entriesIn, entriesOut }; +}; + +const DrinkItemBatchSchema = z.object({ + drinkItemId: z.string(), + quantityOut: z.number().nonnegative(), + inOut: z.string(), + quantityIn: z.number().nonnegative(), + date: z + .string() + .date() + .default(() => new Date().toLocaleDateString("se-SE")), + nrBottles: z.number().nonnegative(), +}); + +export const actions: Actions = { + createDrinkItemBatch: async (event) => { + const { user, prisma } = event.locals; + authorize(apiNames.DRINKITEMBATCH.CREATE, user); + const form = await superValidate(event.request, zod(DrinkItemBatchSchema)); + if (!form.valid) { + return message(form, { message: "inkorrekt värde" }); + } + + if (form.data.inOut == "IN") { + if (form.data.quantityIn === 0) { + return message(form, { message: "Får inte vara 0" }); + } + await prisma.drinkItemBatch.create({ + data: { + drinkItemId: form.data.drinkItemId, + quantityIn: form.data.quantityIn, + date: dayjs(form.data.date).toDate(), + nrBottles: form.data.nrBottles, + user: user.studentId!, + }, + }); + + return message(form, { message: "Antal inskrivet" }); + } + if (form.data.inOut == "OUT") { + if (form.data.quantityOut === 0) { + return message(form, { message: "Får inte vara 0" }); + } + const requestedId = form.data.drinkItemId; + const requestedAmount = form.data.quantityOut; + + const entriesIn = await prisma.drinkItemBatch.findMany({ + where: { + drinkItemId: requestedId, + quantityIn: { gt: 0 }, + }, + }); + const entriesOut = await prisma.drinkItemBatch.findMany({ + where: { + drinkItemId: requestedId, + quantityOut: { gt: 0 }, + }, + }); + const amountIn = entriesIn.reduce((sum, i) => sum + i.quantityIn!, 0); + const amountOut = entriesOut.reduce((sum, i) => sum + i.quantityOut!, 0); + + const availableAmount = amountIn - amountOut; + + if (requestedAmount! > availableAmount) { + return message(form, { + message: `Finns inte ${requestedAmount} i lager`, + }); + } + + await prisma.drinkItemBatch.create({ + data: { + drinkItemId: form.data.drinkItemId, + quantityOut: form.data.quantityOut, + date: dayjs(form.data.date).toDate(), + nrBottles: form.data.nrBottles, + user: user.studentId!, + }, + }); + + return message(form, { message: "Antal utskrivet" }); + } + }, +}; diff --git a/src/routes/(app)/admin/stocklist/stockchange/+page.svelte b/src/routes/(app)/admin/stocklist/stockchange/+page.svelte new file mode 100644 index 000000000..449017ba8 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/stockchange/+page.svelte @@ -0,0 +1,200 @@ + + + + +
+ +
+ +{#if $form.inOut !== ""} + {#if $form.inOut === "IN"} +
+

Skriv in

+
+ + + + + {#if selectedDrinkItem} + {#if selectedDrinkItem!.quantityType === DrinkQuantityType.COUNTS} + i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityIn!, 0) - data.entriesOut.filter((i) => i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityOut!, 0)}`} + /> + + + + + + {:else} + i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityIn!, 0) - data.entriesOut.filter((i) => i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityOut!, 0)}`} + /> + + + + + + + + {/if} + {/if} + +
+ +
+ +
+ {:else} +
+

Skriv ut

+
+ + + {#if selectedDrinkItem} + {#if selectedDrinkItem!.quantityType === DrinkQuantityType.COUNTS} + i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityIn!, 0) - data.entriesOut.filter((i) => i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityOut!, 0)}`} + /> + + + + + + {:else} + i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityIn!, 0) - data.entriesOut.filter((i) => i.drinkItemId === selectedDrinkItem!.id).reduce((sum, i) => sum + i.quantityOut!, 0)}`} + /> + + + + + + + + {/if} + {/if} + +
+ +
+ +
+ {/if} +{/if} diff --git a/src/routes/(app)/admin/stocklist/treasury/+page.server.ts b/src/routes/(app)/admin/stocklist/treasury/+page.server.ts new file mode 100644 index 000000000..9443a5189 --- /dev/null +++ b/src/routes/(app)/admin/stocklist/treasury/+page.server.ts @@ -0,0 +1,245 @@ +import type { Actions, PageServerLoad } from "../$types"; +import { getTotalInventoryValue } from "$lib/utils/getTotalInventoryValue"; +import { DrinkQuantityType, type Prisma } from "@prisma/client"; +import { z } from "zod"; +import { fail, message, superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import { redirect } from "@sveltejs/kit"; +import dayjs from "dayjs"; + +type DrinkItemBatchWithItem = Prisma.DrinkItemBatchGetPayload<{ + include: { item: true }; +}>; + +const deleteSchema = z.object({ + id: z.string(), +}); + +const dateSchema = z.object({ + date: z.string().nullable(), +}); + +const updateSchema = z.object({ + id: z.string(), + date: z.string(), + drinkItemId: z.string(), + quantityIn: z.number().min(0), + quantityOut: z.number().min(0), +}); + +export const load: PageServerLoad = async (event) => { + const { prisma } = event.locals; + const drinks = await prisma.drinkItem.findMany(); + const date = event.url.searchParams.get("date"); + const deleteForm = await superValidate(zod(deleteSchema)); + const updateForm = await superValidate(zod(updateSchema)); + const dateForm = await superValidate({ date: date ?? null }, zod(dateSchema)); + + let entries; + + if (date) { + entries = await prisma.drinkItemBatch.findMany({ + where: { + date: { lte: new Date(date) }, + }, + include: { item: true }, + orderBy: { date: "desc" }, + }); + } else { + entries = await prisma.drinkItemBatch.findMany({ + include: { item: true }, + orderBy: { date: "desc" }, + }); + } + + type DrinkItemBatchWithItem = Prisma.DrinkItemBatchGetPayload<{ + include: { item: true }; + }>; + + const entriesOnDate = + date == null + ? entries + : entries.filter((i) => dayjs(i.date).format("YYYY-MM-DD") === date!); + + const entriesToDate = entries; + + const totalInventoryValue = + date == null + ? (await getTotalInventoryValue(prisma)).totalInventoryValue / 100 + : totalInventoryValueToDate(entriesToDate); + + function totalInventoryValueToDate(entriesToDate: DrinkItemBatchWithItem[]) { + let localTotalInventoryValue = 0; + for (const row of entriesToDate) { + if (row.item.quantityType === DrinkQuantityType.WEIGHT) { + const fullRealWeight = + row.item.bottleFullWeight! - row.item.bottleEmptyWeight!; + const pricePerWeight = row.item.price / fullRealWeight; + + if (row.quantityIn === null) { + const realWeight = + row.quantityOut! - row.item.bottleEmptyWeight! * row.nrBottles!; + console.log(realWeight); + localTotalInventoryValue -= pricePerWeight * realWeight; + } else { + const realWeight = + (row.quantityIn ?? 0) - + row.item.bottleEmptyWeight! * row.nrBottles!; + localTotalInventoryValue += pricePerWeight * realWeight; + } + } else { + if (row.quantityIn === null) { + localTotalInventoryValue -= row.quantityOut! * row.item.price; + } else { + localTotalInventoryValue += row.quantityIn! * row.item.price; + } + } + } + return localTotalInventoryValue / 100; + } + + return { + entriesToDate, + entriesOnDate, + totalInventoryValue, + deleteForm, + updateForm, + dateForm, + drinks, + }; +}; + +export const actions: Actions = { + deleteEntry: async (event) => { + const { prisma, user } = event.locals; + const form = await superValidate(event.request, zod(deleteSchema)); + + if (!form.valid) return fail(400, { form }); + + try { + await prisma.$transaction(async (tx) => { + await tx.drinkItemBatch.delete({ + where: { id: form.data.id }, + }); + + const inventoryResult = await getTotalInventoryValue(tx); + + if (inventoryResult.totalInventoryValue < 0) { + throw new Error("INVENTORY_NEGATIVE"); + } + + const productStock = new Map(); + + for (const batch of inventoryResult.grouped) { + const itemId = batch.item.id; + + const quantity = (batch.quantityIn ?? 0) - (batch.quantityOut ?? 0); + + const currentTotal = productStock.get(itemId) || 0; + productStock.set(itemId, currentTotal + quantity); + } + + for (const [itemId, stock] of productStock) { + if (stock < 0) { + throw new Error("PRODUCT_NEGATIVE"); + } + } + }); + } catch (error) { + if (error instanceof Error && error.message === "INVENTORY_NEGATIVE") { + return message(form, { message: `Totalvärde blir negativt` }); + } else if ( + error instanceof Error && + error.message === "PRODUCT_NEGATIVE" + ) { + return message(form, { message: `Mängd av produkt blir negativt` }); + } + + console.error(error); + return fail(500, { form }); + } + + return message(form, { message: `Batch borttagen` }); + }, + + updateEntry: async ({ request, locals }) => { + const { prisma } = locals; + const form = await superValidate(request, zod(updateSchema)); + + if (!form.valid) return fail(400, { form }); + + try { + await prisma.$transaction(async (tx) => { + await tx.drinkItemBatch.delete({ + where: { id: form.data.id }, + }); + + const inventoryResult = await getTotalInventoryValue(tx); + + if (inventoryResult.totalInventoryValue < 0) { + throw new Error("INVENTORY_NEGATIVE"); + } + + const productStock = new Map(); + + for (const batch of inventoryResult.grouped) { + const itemId = batch.item.id; + + const quantity = (batch.quantityIn ?? 0) - (batch.quantityOut ?? 0); + + const currentTotal = productStock.get(itemId) || 0; + productStock.set(itemId, currentTotal + quantity); + } + + for (const [itemId, stock] of productStock) { + if (stock < 0) { + throw new Error("PRODUCT_NEGATIVE"); + } + } + }); + } catch (error) { + if (error instanceof Error && error.message === "INVENTORY_NEGATIVE") { + return message(form, { message: `Totalvärde blir negativt` }); + } else if ( + error instanceof Error && + error.message === "PRODUCT_NEGATIVE" + ) { + return message(form, { message: `Mängd av produkt blir negativt` }); + } + + console.error(error); + return fail(500, { form }); + } + + try { + await prisma.drinkItemBatch.update({ + where: { id: form.data.id }, + data: { + date: new Date(form.data.date), + drinkItemId: form.data.drinkItemId, + quantityIn: form.data.quantityIn, + quantityOut: form.data.quantityOut, + }, + }); + } catch (err) { + return fail(500, { message: "Failed to update" }); + } + + redirect(302, request.url); + }, + + redirectDate: async (event) => { + const form = await superValidate(event.request, zod(dateSchema)); + + if (!form.valid) return fail(400, { form }); + + if (form.data.date === null) { + redirect(302, "treasury"); + } + + const date = dayjs(form.data.date).format("YYYY-MM-DD"); + event.url.searchParams.set("date", date); + event.url.searchParams.delete("/redirectDate"); + redirect(302, event.url); + }, +}; diff --git a/src/routes/(app)/admin/stocklist/treasury/+page.svelte b/src/routes/(app)/admin/stocklist/treasury/+page.svelte new file mode 100644 index 000000000..973797a4c --- /dev/null +++ b/src/routes/(app)/admin/stocklist/treasury/+page.svelte @@ -0,0 +1,198 @@ + + + { + $dateForm.date = date; + dateFormElement.submit(); + }} +/> + + + +
+ + + + + + + + + + + + + + + {#each data.entriesOnDate as entry} + {@const formId = `form-${entry.id}`} + + {#if editingId === entry.id} + + + + + + + + + + + + + + + + {:else} + + + + + + + + + + + + + + + + {/if} + {/each} + +
DatumNamnAnta/Vikt InAntal/Vikt UtAnvändareÄndraTa bort
+ + + + + + + + + + {entry.user} + + + + +
{new Date(entry.date).toLocaleDateString("sv-SE")}{entry.item.name}{entry.quantityIn ?? 0}{entry.quantityOut ?? 0}{entry.user} + + +
+ + +
+
+
diff --git a/src/routes/(app)/api/admin/stocklist/+server.ts b/src/routes/(app)/api/admin/stocklist/+server.ts new file mode 100644 index 000000000..200968c9e --- /dev/null +++ b/src/routes/(app)/api/admin/stocklist/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from "@sveltejs/kit"; + +export const DELETE: RequestHandler = async ({ url, locals }) => { + const { prisma } = locals; + + const id = url.searchParams.get("id")!; + + await prisma.drinkItemBatch.delete({ + where: { id: id }, + }); + + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 0a0c2bd5f..bbe9b384e 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -220,6 +220,13 @@ export const getRoutes = (): Route[] => accessRequired: apiNames.MARKDOWN.CREATE, appBehaviour: "home-link", }, + { + title: m.stocklist(), + path: "/admin/stocklist", + icon: "i-mdi-text-box-edit", + accessRequired: apiNames.DRINKITEM.READ, + appBehaviour: "home-link", + }, ], }, ] as const; diff --git a/src/translations/en.json b/src/translations/en.json index 2982f5541..74ee97194 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -832,6 +832,7 @@ "pub_example": "Food to pub", "add_receipt": "Add receipt", "add_row": "Add row", + "stocklist": "Stocklist", "minecraft_server_description": "Join the Guild's Minecraft Server, shared with the F-Guild! To join the server you have to sign up with the button below.", "minecraft_dynmap": "Dynmap", "minecraft_join_prompt": "Join now!", diff --git a/src/translations/sv.json b/src/translations/sv.json index 0c5dd3eea..f8f938f33 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -829,6 +829,7 @@ "pub_example": "Mat till pub", "add_receipt": "Lägg till kvitto", "add_row": "Lägg till rad", + "stocklist": "Stocklist", "minecraft_server_description": "Gå med i Sektionens Minecraft Server, delad med F-sektionen! För att kunna gå med måste du registrera dig nedan.", "minecraft_dynmap": "Dynmap", "minecraft_join_prompt": "Gå med nu!",