18388bb31c
- 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>
228 lines
7.4 KiB
TypeScript
228 lines
7.4 KiB
TypeScript
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>
|
||
);
|
||
}
|