From 610910ccf0103832ae57902a3d148f4b0a291b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:22:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20matlager-hantering=20?= =?UTF-8?q?=E2=80=93=20s=C3=B6k,=20plats-filter,=20markera=20flera,=20mass?= =?UTF-8?q?radering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: POST /v1/inventory/bulk-delete (mjuk radering, ägar-kontroll, correction-tx) - Mobil: "Ditt kök" görs om till hanteringsvy – sök, filter Kyl/Frys/Skafferi, sortering på plats+namn, markera flera (som mejlklient), Ta bort valda / Töm listan - Hem: "+N till" blir klickbar och tar dig till Ditt kök --- apps/api/src/routes/inventory.ts | 54 ++++++- apps/mobile/src/app/(tabs)/home.tsx | 4 +- apps/mobile/src/app/kitchen.tsx | 230 +++++++++++++++++++--------- 3 files changed, 216 insertions(+), 72 deletions(-) diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts index e8d90ec..05db897 100644 --- a/apps/api/src/routes/inventory.ts +++ b/apps/api/src/routes/inventory.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { and, desc, eq, gt, ilike, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, gt, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { createInventoryItemInputSchema, @@ -603,6 +603,58 @@ export async function inventoryRoutes(app: FastifyInstance) { .where(eq(schema.inventoryItems.id, id)); return { ok: true }; }); + + // Massradering: markera flera eller töm en hel vy. Mjuk radering som ovan. + app.post("/v1/inventory/bulk-delete", auth, async (req) => { + const body = req.body as { ids?: unknown }; + const ids = Array.isArray(body?.ids) + ? body.ids.filter((x): x is string => typeof x === "string").slice(0, 1000) + : []; + if (ids.length === 0) throw errors.badRequest("ids saknas."); + const householdId = await requireActiveHousehold(app.db, req.userId); + const owned = await app.db + .select({ + id: schema.inventoryItems.id, + quantity: schema.inventoryItems.quantity, + unit: schema.inventoryItems.unit, + }) + .from(schema.inventoryItems) + .where( + and( + inArray(schema.inventoryItems.id, ids), + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + ), + ); + if (owned.length === 0) return { ok: true, deleted: 0 }; + const corrections = owned + .filter((i) => i.quantity > 0) + .map((i) => ({ + householdId, + inventoryItemId: i.id, + type: "correction" as const, + quantityDelta: -i.quantity, + unit: i.unit, + actorUserId: req.userId, + note: "Massradering av användare", + })); + if (corrections.length > 0) { + await app.db.insert(schema.inventoryTransactions).values(corrections); + } + await app.db + .update(schema.inventoryItems) + .set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() }) + .where( + and( + inArray( + schema.inventoryItems.id, + owned.map((i) => i.id), + ), + eq(schema.inventoryItems.householdId, householdId), + ), + ); + return { ok: true, deleted: owned.length }; + }); } async function getOwnedItem(app: FastifyInstance, itemId: string, userId: string) { diff --git a/apps/mobile/src/app/(tabs)/home.tsx b/apps/mobile/src/app/(tabs)/home.tsx index 2f2321d..8fdd20a 100644 --- a/apps/mobile/src/app/(tabs)/home.tsx +++ b/apps/mobile/src/app/(tabs)/home.tsx @@ -188,7 +188,9 @@ export default function HomeScreen() { ))} {locationItems.length > 12 && ( - {t("home.moreItems", { count: locationItems.length - 12 })} + router.push("/kitchen")}> + {t("home.moreItems", { count: locationItems.length - 12 })} › + )} ))} diff --git a/apps/mobile/src/app/kitchen.tsx b/apps/mobile/src/app/kitchen.tsx index a7ed93f..688be88 100644 --- a/apps/mobile/src/app/kitchen.tsx +++ b/apps/mobile/src/app/kitchen.tsx @@ -1,87 +1,177 @@ -import { Alert } from "react-native"; +import { useState } from "react"; +import { Alert, TextInput } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; import { t } from "@/lib/i18n"; import { formatQuantity } from "@/lib/units"; import { - Body, - Button, - Card, - EmptyState, - ErrorView, - LoadingView, - Row, - Screen, - Small, + Body, + Button, + Card, + EmptyState, + ErrorView, + LoadingView, + Row, + Screen, + Small, } from "@/components/ui"; +import { colors, radius, spacing } from "@/lib/theme"; -/** Ditt kök: se och ta bort varor i lagret. Radering = mjuk (kvantitet → 0, historik bevaras). */ +/** Ditt kök: sök, sortera, markera flera och ta bort. Radering = mjuk (kvantitet → 0, historik bevaras). */ interface InventoryItem { - id: string; - displayName: string; - quantity: number; - unit: string; - expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean }; + id: string; + displayName: string; + quantity: number; + unit: string; + locationName: string; + locationType: string; + expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean }; } +const LOCATION_LABELS: Record = { + fridge: "Kyl", + freezer: "Frys", + pantry: "Skafferi", + garage_freezer: "Garagefrys", + wine_fridge: "Vinkyl", +}; +const locLabel = (type: string) => LOCATION_LABELS[type] ?? type; + export default function KitchenScreen() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [locFilter, setLocFilter] = useState(null); + const [selected, setSelected] = useState>(new Set()); - const query = useQuery({ - queryKey: ["inventory", "all"], - queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"), - }); + const query = useQuery({ + queryKey: ["inventory", "all"], + queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=500"), + }); - const remove = useMutation({ - mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }), - onMutate: async (id: string) => { - await queryClient.cancelQueries({ queryKey: ["inventory", "all"] }); - const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]); - queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) => - old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old, - ); - return { previous }; - }, - onError: (err, _id, context) => { - if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous); - Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); - }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["inventory"] }); - void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] }); - }, - }); + const bulkRemove = useMutation({ + mutationFn: (ids: string[]) => + api<{ deleted: number }>("/v1/inventory/bulk-delete", { method: "POST", body: { ids } }), + onSuccess: () => setSelected(new Set()), + onError: (err) => + Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["inventory"] }); + void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] }); + }, + }); - if (query.isLoading) return ; - if (query.isError) return void query.refetch()} />; - const items = query.data?.items ?? []; + if (query.isLoading) return ; + if (query.isError) return void query.refetch()} />; + const all = query.data?.items ?? []; - return ( - - {items.length === 0 && ( - - )} - {items.map((item) => ( - - - - {item.displayName} · {formatQuantity(item.quantity, item.unit)} - -