Files
Cibello-app/apps/mobile/src/app/kitchen.tsx
T
Claude 18388bb31c feat(i18n): översätt kvarvarande vyer + härda vakten (audit-backlog)
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
  receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
  substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
  matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
  10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
  varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
  strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
  decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
  11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
  (blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 20:07:00 +00:00

228 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { Alert, Pressable, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
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,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Matlager: allt du har hemma, städat per plats (Kyl/Frys/Skafferi …).
* Sök, filtrera på plats, markera flera och ta bort. Radering = mjuk
* (kvantitet → 0, historik bevaras).
*/
interface InventoryItem {
id: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
locationType: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const locLabel = (type: string) =>
LOCATION_ORDER.includes(type) ? t(`home.location.${type}`) : type;
const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i;
};
export default function KitchenScreen() {
const queryClient = useQueryClient();
const params = useLocalSearchParams<{ loc?: string }>();
const initialLoc = typeof params.loc === "string" ? params.loc : null;
const [search, setSearch] = useState("");
const [locFilter, setLocFilter] = useState<string | null>(initialLoc);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({
queryKey: ["inventory", "all"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=500"),
});
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"] });
// Lagret styr receptförslagen uppdatera "Vad ska vi äta?" direkt.
void queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
},
});
const all = query.data?.items ?? [];
const locTypes = useMemo(() => {
const set = new Set(all.map((i) => i.locationType));
return [...set].sort((a, b) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"));
}, [all]);
const q = search.trim().toLowerCase();
const visible = useMemo(
() =>
all
.filter((i) => (locFilter ? i.locationType === locFilter : true))
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true)),
[all, locFilter, q],
);
const groups = useMemo(() => {
const map = new Map<string, InventoryItem[]>();
for (const it of visible) {
const list = map.get(it.locationType) ?? [];
list.push(it);
map.set(it.locationType, list);
}
return [...map.entries()]
.sort(([a], [b]) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"))
.map(([type, items]) => ({
type,
items: [...items].sort((x, y) => x.displayName.localeCompare(y.displayName, "sv")),
}));
}, [visible]);
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
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(t("common.remove"), t("kitchen.deleteConfirm", { label }), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.remove"), style: "destructive", onPress: () => bulkRemove.mutate(ids) },
]);
};
const emptyLabel = locFilter
? locLabel(locFilter)
: q
? t("kitchen.scopeSearch")
: t("kitchen.scopeAll");
return (
<Screen>
<Input
value={search}
onChangeText={setSearch}
placeholder={t("kitchen.searchPlaceholder")}
autoCorrect={false}
/>
<Row>
<Button
label={t("common.all")}
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" }}>
<Small>
{t("kitchen.itemCount", { count: visible.length })}
{selected.size > 0 ? ` · ${t("kitchen.selectedCount", { count: selected.size })}` : ""}
</Small>
<Row style={{ justifyContent: "flex-end" }}>
{selected.size > 0 && (
<Button
label={t("kitchen.removeSelected", { count: selected.size })}
variant="danger"
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
/>
)}
{visible.length > 0 && (
<Button
label={t("kitchen.clearList")}
variant="ghost"
onPress={() =>
confirmDelete(
visible.map((i) => i.id),
emptyLabel,
)
}
/>
)}
</Row>
</Row>
{visible.length === 0 && <EmptyState text={t("kitchen.empty")} />}
{groups.map((g) => (
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{locLabel(g.type)}</Heading>
<Small>{g.items.length} st</Small>
</Row>
{g.items.map((item) => {
const isSel = selected.has(item.id);
return (
<Card
key={item.id}
onPress={() => toggle(item.id)}
style={
isSel
? { borderColor: colors.primary, backgroundColor: colors.primarySoft }
: undefined
}
>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
{isSel ? "☑ " : "☐ "}
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Row style={{ alignItems: "center", gap: 12 }}>
{item.expiry.pastBestBefore && <Small> {t("kitchen.pastBefore")}</Small>}
<Pressable
onPress={() => confirmDelete([item.id], locLabel(item.locationType))}
hitSlop={8}
>
<Small style={{ color: colors.danger, fontWeight: "600" }}>
{t("common.remove")}
</Small>
</Pressable>
</Row>
</Row>
</Card>
);
})}
</View>
))}
</Screen>
);
}