feat(inventory): matlager-hantering – sök, plats-filter, markera flera, massradering

- 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
This commit is contained in:
Claude
2026-08-18 15:22:59 +00:00
parent 684743ae95
commit 610910ccf0
3 changed files with 216 additions and 72 deletions
+53 -1
View File
@@ -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) {
+3 -1
View File
@@ -188,7 +188,9 @@ export default function HomeScreen() {
</Row>
))}
{locationItems.length > 12 && (
<Small>{t("home.moreItems", { count: locationItems.length - 12 })}</Small>
<Pressable onPress={() => router.push("/kitchen")}>
<Small>{t("home.moreItems", { count: locationItems.length - 12 })} </Small>
</Pressable>
)}
</Card>
))}
+160 -70
View File
@@ -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<string, string> = {
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<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(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 <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const items = query.data?.items ?? [];
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const all = query.data?.items ?? [];
return (
<Screen>
{items.length === 0 && (
<EmptyState text="Ditt kök är tomt än. Skanna eller lägg till varor." />
)}
{items.map((item) => (
<Card key={item.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Button
label="Ta bort"
variant="ghost"
onPress={() =>
Alert.alert("Ta bort", `Ta bort ${item.displayName} ur ditt kök?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
])
}
/>
</Row>
{item.expiry.pastBestBefore && <Small> bäst före har passerat</Small>}
</Card>
))}
</Screen>
);
const locTypes = Array.from(new Set(all.map((i) => i.locationType)));
const q = search.trim().toLowerCase();
const visible = all
.filter((i) => (locFilter ? i.locationType === locFilter : true))
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true))
.sort(
(a, b) =>
a.locationName.localeCompare(b.locationName, "sv") ||
a.displayName.localeCompare(b.displayName, "sv"),
);
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const confirmDelete = (ids: string[], label: string) => {
if (ids.length === 0) return;
Alert.alert("Ta bort", `Är du säker på att du vill ta bort ${ids.length} st ur ${label}?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
]);
};
return (
<Screen>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Sök vara…"
placeholderTextColor={colors.textMuted}
style={{
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.text,
backgroundColor: colors.surface,
marginBottom: spacing.sm,
}}
/>
<Row style={{ flexWrap: "wrap", gap: spacing.xs }}>
<Button
label="Alla"
variant={locFilter === null ? "primary" : "ghost"}
onPress={() => setLocFilter(null)}
/>
{locTypes.map((tp) => (
<Button
key={tp}
label={locLabel(tp)}
variant={locFilter === tp ? "primary" : "ghost"}
onPress={() => setLocFilter(tp)}
/>
))}
</Row>
<Row
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: spacing.sm }}
>
<Small>
{visible.length} varor{selected.size > 0 ? ` · ${selected.size} valda` : ""}
</Small>
<Row style={{ gap: spacing.xs }}>
{selected.size > 0 && (
<Button
label={`Ta bort valda (${selected.size})`}
variant="danger"
onPress={() => confirmDelete([...selected], "markeringen")}
/>
)}
<Button
label="Töm listan"
variant="ghost"
onPress={() =>
confirmDelete(
visible.map((i) => i.id),
locFilter ? locLabel(locFilter) : "hela lagret",
)
}
/>
</Row>
</Row>
{visible.length === 0 && (
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
)}
{visible.map((item) => {
const isSel = selected.has(item.id);
return (
<Card key={item.id} onPress={() => toggle(item.id)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
{isSel ? "☑ " : "☐ "}
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Small>{locLabel(item.locationType)}</Small>
</Row>
{item.expiry.pastBestBefore && <Small> bäst före har passerat</Small>}
</Card>
);
})}
</Screen>
);
}