Files
Cibello-app/apps/mobile/src/app/shopping.tsx
T
2026-08-05 19:21:11 +07:00

206 lines
6.1 KiB
TypeScript

import { useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Inköpslista (spec §27): delad, avdelningssorterad, uppdaterar lagret vid avslut. */
interface ShoppingItem {
id: string;
displayName: string;
quantity: number;
unit: string;
storeSection: string;
estimatedPriceMinor: number | null;
checked: boolean;
}
interface ListResponse {
list: { id: string; name: string };
items: ShoppingItem[];
estimatedTotalMinor: number;
currency: string;
}
/** Butikssektionernas etiketter bor i i18n-katalogen: shopping.section.<id> (12 språk). */
const SECTION_IDS = new Set([
"frukt_gront",
"brod",
"mejeri",
"kott_fagel",
"fisk",
"chark",
"frys",
"skafferi",
"konserver",
"kryddor_bak",
"dryck",
"snacks",
"hygien_ovrigt",
]);
const sectionLabel = (id: string): string =>
SECTION_IDS.has(id) ? t(`shopping.section.${id}`) : id;
export default function ShoppingScreen() {
const queryClient = useQueryClient();
const [newItem, setNewItem] = useState("");
const lists = useQuery({
queryKey: ["shopping-lists"],
queryFn: async () => {
const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists");
if (result.lists.length === 0) {
const created = await api<{ list: { id: string } }>("/v1/shopping-lists", {
method: "POST",
body: { name: t("shopping.title") },
});
return [created.list];
}
return result.lists;
},
});
const listId = lists.data?.[0]?.id;
const list = useQuery({
queryKey: ["shopping-list", listId],
queryFn: () => api<ListResponse>(`/v1/shopping-lists/${listId}`),
enabled: Boolean(listId),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["shopping-list", listId] });
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
};
const addItem = useMutation({
mutationFn: (displayName: string) =>
api(`/v1/shopping-lists/${listId}/items`, { method: "POST", body: { displayName } }),
onSuccess: () => {
setNewItem("");
invalidate();
},
});
const toggle = useMutation({
mutationFn: (item: ShoppingItem) =>
api(`/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PATCH",
body: { checked: !item.checked },
}),
onSuccess: invalidate,
});
const complete = useMutation({
mutationFn: () =>
api(`/v1/shopping-lists/${listId}/complete`, {
method: "POST",
body: { addToInventory: true },
}),
onSuccess: async (result) => {
const added = (result as { itemsAddedToInventory?: number }).itemsAddedToInventory ?? 0;
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
invalidate();
Alert.alert(t("shopping.completedTitle"), t("shopping.completedBody", { count: added }));
},
});
if (lists.isLoading || list.isLoading) return <LoadingView />;
if (list.isError || !list.data) return <ErrorView onRetry={() => void list.refetch()} />;
const grouped = new Map<string, ShoppingItem[]>();
for (const item of list.data.items) {
const arr = grouped.get(item.storeSection) ?? [];
arr.push(item);
grouped.set(item.storeSection, arr);
}
const checkedCount = list.data.items.filter((i) => i.checked).length;
return (
<Screen>
<Row>
<View style={{ flex: 1 }}>
<Input
placeholder={t("shopping.addPlaceholder")}
value={newItem}
onChangeText={setNewItem}
onSubmitEditing={() => newItem.trim() && addItem.mutate(newItem.trim())}
returnKeyType="done"
/>
</View>
<Button label="+" onPress={() => newItem.trim() && addItem.mutate(newItem.trim())} />
</Row>
{list.data.items.length === 0 && <EmptyState text={t("shopping.empty")} />}
{[...grouped.entries()].map(([section, items]) => (
<Card key={section}>
<Heading>{sectionLabel(section)}</Heading>
{items.map((item) => (
<Pressable key={item.id} onPress={() => toggle.mutate(item)}>
<Row style={{ justifyContent: "space-between", paddingVertical: 6 }}>
<Body>
<Text style={{ color: item.checked ? colors.primary : colors.textMuted }}>
{item.checked ? "☑" : "☐"}
</Text>{" "}
<Text
style={
item.checked
? { textDecorationLine: "line-through", color: colors.textMuted }
: undefined
}
>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Text>
</Body>
{item.estimatedPriceMinor != null && (
<Small>
{t("shopping.estimated", {
amount: formatMinor(item.estimatedPriceMinor, list.data.currency),
})}
</Small>
)}
</Row>
</Pressable>
))}
</Card>
))}
{list.data.estimatedTotalMinor > 0 && (
<Small>
{t("shopping.estimatedTotal", {
amount: formatMinor(list.data.estimatedTotalMinor, list.data.currency),
})}
</Small>
)}
<Spacer size={spacing.sm} />
{checkedCount > 0 && (
<>
<Button
label={`${t("shopping.complete")} (${checkedCount})`}
onPress={() => complete.mutate()}
loading={complete.isPending}
/>
<Small>{t("shopping.completeNote")}</Small>
</>
)}
</Screen>
);
}