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:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user