Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions frontend/src/app/(main)/admin/feeds/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,17 @@ export default function AdminFeedsPage() {
loadAll();
}, [loadAll]);

// N7: a feed name is unique per country, not library-wide, so the list
// can be narrowed to one country on screen ("" = all).
const [countryFilter, setCountryFilter] = useState("");
const countryNames = Array.from(new Set(countries.map((c) => c.name).filter(Boolean))).sort(
(a, b) => a.localeCompare(b)
);

// Match Android FragmentFeed search: filter by feed name only.
const filteredFeeds = feeds.filter((f) => {
if (countryFilter && (f.fd_country_name ?? "").toLowerCase() !== countryFilter.toLowerCase())
return false;
if (!searchQuery) return true;
return f.fd_name?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false;
});
Expand Down Expand Up @@ -386,10 +395,13 @@ export default function AdminFeedsPage() {
{t("Management")}
</p>

{/* ── FEEDS TAB ── (matches Android fragment_feed.xml: search-by-name only,
no filter dropdowns; FAB for add) */}
{/* ── FEEDS TAB ── (Android fragment_feed.xml: search by name; FAB for
add) plus a country filter (N7) */}
{tab === "feeds" && (
<FeedsTab
countryFilter={countryFilter}
countryNames={countryNames}
setCountryFilter={setCountryFilter}
deletingFeedId={deletingFeedId}
filteredFeeds={filteredFeeds}
isLoading={isLoading}
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/app/(main)/cattle-info/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useRouter } from "next/navigation";
import { useStore } from "@/lib/store";
import { useDrawer } from "@/lib/DrawerContext";
import { getCountries, getUserReports, getSimulationDetails, getCattleInfoFields } from "@/lib/api";
import type { AnimalCategory } from "@/lib/api";
import type { AnimalCategory, CattleInfo } from "@/lib/api";
import { useT } from "@/lib/i18n-ui";
import Toolbar from "@/components/Toolbar";
import { COLUMN_WIDTH } from "@/lib/column";
Expand All @@ -32,6 +32,7 @@ import { ACTION_FEEDBACK_MS } from "@/lib/ui-timings";
import { COLORS } from "@/lib/colors";
import { useLatestRef } from "@/lib/use-latest";
import GeneratingReportDialog from "@/components/GeneratingReportDialog";
import { useCountryChange } from "@/components/cattle-info/useCountryChange";
import { useCalfRun } from "@/components/cattle-info/useCalfRun";
import {
Adjustments,
Expand Down Expand Up @@ -397,12 +398,18 @@ export default function CattleInfoPage() {
return resolved;
})(),
};
// N8: a new country with filled feed cards asks before clearing them.
countryChange.continueWith(nextInfo);
};

function proceed(nextInfo: CattleInfo) {
setCattleInfo(nextInfo);
// A calf only needs its milk amount — skip feed selection (QA row 8);
// the backend works out the plan straight away (W13).
if (form.animal_category === "Baby Calf/Heifer") void calfRun.run(nextInfo);
if (nextInfo.animal_category === "Baby Calf/Heifer") void calfRun.run(nextInfo);
else router.push("/feed-selection");
};
}
const countryChange = useCountryChange(proceed, t);

// The reset itself finishes in microseconds; the user was reporting
// they couldn't tell whether the button had done anything. This state
Expand Down Expand Up @@ -568,6 +575,8 @@ export default function CattleInfoPage() {

{calfRun.isRunning && <GeneratingReportDialog />}

{countryChange.dialog}

{/* Simulation History Bottom-Sheet Modal */}
{showHistoryModal && (
<SimulationHistorySheet
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/app/(main)/feed-selection/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import GeneratingReportDialog from "@/components/GeneratingReportDialog";
import { evaluateDiet, recommendDiet } from "@/lib/api";
import { simulationCurrency } from "@/lib/money";
import { useSimulationUser } from "@/lib/simulation-country";
import { isFeedOnOtherCard } from "@/lib/feed-selection-rules";
import type { DietLimits, FeedSearchResult } from "@/lib/api";
import { IcAddFeed } from "@/components/Icons";
import { useT } from "@/lib/i18n-ui";
Expand Down Expand Up @@ -142,6 +143,11 @@ export default function FeedSelectionPage() {
if (idx === -1) idx = 0;
const landedIdx = idx;
const landedRowId = items[idx]?.id ?? null;
// One feed per card: picking a feed another card holds changes nothing.
if (isFeedOnOtherCard(items, result.feed_uuid, landedRowId)) {
showSnackbar(t("Already this feed is selected"), "error");
return;
}

setItems((prev) => {
const next = prev.map((row) =>
Expand Down Expand Up @@ -411,6 +417,7 @@ export default function FeedSelectionPage() {
onUpdate={updateItem}
onDelete={deleteItem}
taxonomyLabels={taxonomyLabels}
isFeedTaken={(feedUuid, rowId) => isFeedOnOtherCard(items, feedUuid, rowId)}
/>
</div>
))}
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/app/__tests__/admin-feeds.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,3 +678,56 @@ describe("Admin Feeds — UI-label translation (preferred_language: hi)", () =>
expect(screen.getByRole("button", { name: "Feed Type" })).toBeInTheDocument();
});
});

// ─── N7: the same feed name in two countries ─────────────────────────────────

describe("Admin Feeds — feed country (N7)", () => {
const kenyaX = { ...sampleFeed, feed_id: "x-ke", fd_name: "X", fd_country_name: "Kenya" };
const indiaX = { ...sampleFeed, feed_id: "x-in", fd_name: "X", fd_country_name: "India" };
const twoCountries = [
...countries,
{ id: "2", name: "Kenya", country_code: "KE", currency: "KES" },
];

async function openFeeds() {
resolveLoadAll({ feeds: [kenyaX, indiaX], countriesData: twoCountries });
render(<AdminFeedsPage />);
await waitForLoaded();
fireEvent.click(screen.getByRole("button", { name: "Feed" }));
await waitFor(() => expect(screen.getAllByText("X")).toHaveLength(2));
}
const cardOf = (country: string) =>
screen
.getAllByText("X")
.map((n) => n.closest(".mx-3") as HTMLElement)
.find((c) => within(c).queryByText(country)) as HTMLElement;

it("each card shows its feed's country", async () => {
await openFeeds();
expect(cardOf("Kenya")).toBeTruthy();
expect(cardOf("India")).toBeTruthy();
expect(cardOf("Kenya")).not.toBe(cardOf("India"));
});

it("the country filter lists only that country's feeds", async () => {
await openFeeds();
fireEvent.click(screen.getByRole("button", { name: /All Countries/ }));
fireEvent.click(await screen.findByRole("option", { name: "India" }));

await waitFor(() => expect(screen.getAllByText("X")).toHaveLength(1));
expect(cardOf("India")).toBeTruthy();
expect(screen.queryByText("Kenya")).toBeNull();
});

it("the delete dialog names the feed's country", async () => {
await openFeeds();
fireEvent.click(within(cardOf("India")).getByRole("button", { name: "Delete feed" }));

const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
'"X" (India) will be permanently deleted. This action cannot be undone.'
)
).toBeInTheDocument();
});
});
145 changes: 145 additions & 0 deletions frontend/src/app/__tests__/cattle-info.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1466,3 +1466,148 @@ describe("Cattle Info — forward arrow to the last report (F47 / W3)", () => {
expect(screen.queryByRole("button", { name: "Forward" })).toBeNull();
});
});

// ─── N8: changing the country clears the feed cards, after asking ──────────
describe("Cattle Info — country change clears the feed cards (N8)", () => {
const card = (id: string, feed: string | null): FeedItem =>
({
id,
feed_type_id: null,
feed_type_name: "Forage",
category_id: null,
category_name: "Grass",
sub_category_id: feed ? 1 : null,
sub_category_name: feed ?? "",
feed_uuid: feed ? `uuid-${feed}` : null,
price_per_kg: 5,
quantity_kg: null,
inclusion_limits_enabled: true,
min_kg_per_day: 1,
max_kg_per_day: 3,
}) as FeedItem;
const savedIndia: CattleInfo = {
simulation_name: "Old",
country: "India",
country_id: "1",
currency: "INR",
animal_category: "Lactating Cow",
breed: "Local",
body_weight: 450,
body_weight_gain: 0.5,
body_condition_score: 3,
parity: 2,
days_in_milk: 100,
days_of_pregnancy: 0,
milk_production: 10,
milk_protein_percent: 3,
milk_fat_percent: 4,
average_temperature: 28,
grazing: false,
distance: 0,
topography: "Flat",
milk_price: null,
simulation_language: null,
};
const twoCards = [card("a", "Napier"), card("b", "Maize")];

async function continueWith(country: string) {
getCountries.mockResolvedValueOnce({ data: countries });
render(<CattleInfoPage />);
await formReady();
await fillBaselineRequired(country);
const btn = screen.getByRole("button", { name: "Continue to Feed" });
await waitFor(() => expect(btn).not.toBeDisabled());
fireEvent.click(btn);
}

it("asks first; Continue clears every card and saves the new country", async () => {
useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards });
await continueWith("Vietnam");

const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText("Changing the country clears the 2 selected feeds. Continue?")
).toBeInTheDocument();
expect(push).not.toHaveBeenCalled();
expect(useStore.getState().cattleInfo?.country_id).toBe("1");

fireEvent.click(within(dialog).getByRole("button", { name: "Continue" }));
expect(useStore.getState().feedSelections).toEqual([]);
expect(useStore.getState().cattleInfo).toMatchObject({ country_id: "2", currency: "VND" });
expect(push).toHaveBeenCalledWith("/feed-selection");
});

it("Cancel changes nothing and saves nothing", async () => {
useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards });
await continueWith("Vietnam");

fireEvent.click(
within(await screen.findByRole("dialog")).getByRole("button", { name: "Cancel" })
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(useStore.getState().feedSelections).toEqual(twoCards);
expect(useStore.getState().cattleInfo).toBe(savedIndia);
expect(push).not.toHaveBeenCalled();
});

it("does not ask when the country ends up the same (India → Vietnam → India)", async () => {
useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards });
getCountries.mockResolvedValueOnce({ data: countries });
render(<CattleInfoPage />);
await formReady();
await selectCountry("Vietnam");
await fillBaselineRequired("India");
fireEvent.click(screen.getByRole("button", { name: "Continue to Feed" }));

expect(screen.queryByRole("dialog")).toBeNull();
expect(useStore.getState().feedSelections).toEqual(twoCards);
expect(push).toHaveBeenCalledWith("/feed-selection");
});

it("does not ask when no card has a feed", async () => {
const empty = [card("a", null)];
useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: empty });
await continueWith("Vietnam");

expect(screen.queryByRole("dialog")).toBeNull();
expect(useStore.getState().feedSelections).toEqual(empty);
expect(useStore.getState().cattleInfo?.country_id).toBe("2");
});

it("a restored simulation keeps its feeds and Continue does not ask", async () => {
useStore.setState({
user: seedUser(),
cattleInfo: { ...savedIndia, country: "Vietnam", country_id: "2" },
feedSelections: twoCards,
});
getCountries.mockResolvedValueOnce({ data: countries });
getUserReports.mockResolvedValueOnce({
data: { simulations: [{ report_id: "R-1", simulation_id: "SIM-1", country_name: "India" }] },
});
getSimulationDetails.mockResolvedValueOnce({
data: {
simulation_id: "SIM-1",
country_name: "India",
cattle_info: { tp_milk: 3.0, fat_milk: 4.0, grazing: false },
feed_selection: [
{ feed_type: "Forage", feed_category: "Grass", feed_name: "Napier", feed_id: "uuid-1" },
],
},
});
render(<CattleInfoPage />);
await formReady();
fireEvent.click(screen.getByRole("button", { name: "Simulation history" }));
fireEvent.click(await screen.findByText("SIM-1"));
await waitFor(() => expect(useStore.getState().cattleInfo?.country_id).toBe("1"));
expect(screen.queryByRole("dialog")).toBeNull();

fillSimulationName();
const btn = screen.getByRole("button", { name: "Continue to Feed" });
await waitFor(() => expect(btn).not.toBeDisabled());
fireEvent.click(btn);

expect(screen.queryByRole("dialog")).toBeNull();
expect(useStore.getState().feedSelections.map((f) => f.feed_uuid)).toEqual(["uuid-1"]);
expect(push).toHaveBeenCalledWith("/feed-selection");
});
});
64 changes: 64 additions & 0 deletions frontend/src/app/__tests__/feed-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1032,3 +1032,67 @@ describe("feed-selection — simulation country (N5)", () => {
expect(useStore.getState().user?.country_id).toBe("1");
});
});

describe("feed-selection — one feed per card (search)", () => {
it("a search result already on another card is refused with a message", async () => {
const napier = {
feed_uuid: "napier",
feed_name: "Napier",
feed_type: "Forage",
feed_category: "Grass",
display_name: "Napier",
display_type: "Forage",
display_category: "Grass",
};
const filled = {
id: "r1",
feed_type_id: null,
feed_type_name: "Forage",
category_id: null,
category_name: "Grass",
sub_category_id: 1,
sub_category_name: "Napier",
feed_uuid: "napier",
price_per_kg: null,
quantity_kg: null,
inclusion_limits_enabled: false,
min_kg_per_day: null,
max_kg_per_day: null,
};
useStore.setState({
feedSelections: [
filled,
{ ...filled, id: "r2", sub_category_name: "", feed_uuid: null, sub_category_id: null },
],
});
searchFeeds.mockReset().mockResolvedValue({ data: [napier] });
// The filled card's own lists, so it settles like a real one.
getFeedCategories.mockReset().mockResolvedValue({ data: ["Grass"] });
getFeedSubCategories.mockReset().mockResolvedValue({
data: { standard_feeds: [{ feed_id: "napier", fd_name: "Napier" }], custom_feeds: [] },
});
await renderReady();

fireEvent.change(screen.getByPlaceholderText(/Search feeds/i), { target: { value: "nap" } });
await waitFor(() => expect(searchFeeds).toHaveBeenCalled());
// The result row (it also shows the category), not the card's dropdown.
const result = await waitFor(() => {
const row = screen
.getAllByRole("button", { name: /Napier/ })
.find((b) => b.textContent?.includes("Grass"));
expect(row).toBeTruthy();
return row as HTMLElement;
});
fireEvent.click(result);

await waitFor(() =>
expect(useStore.getState().snackbar).toMatchObject({
message: "Already this feed is selected",
type: "error",
})
);
expect(useStore.getState().feedSelections.filter((f) => f.feed_uuid === "napier")).toHaveLength(
1
);
});
});
Loading
Loading