diff --git a/services/frontend/src/components/common/lists/ContributionUserList.vue b/services/frontend/src/components/common/lists/ContributionUserList.vue index bdd4151bd..708f96d8c 100644 --- a/services/frontend/src/components/common/lists/ContributionUserList.vue +++ b/services/frontend/src/components/common/lists/ContributionUserList.vue @@ -55,6 +55,7 @@ disabled?: boolean startOpen?: boolean }>(), { panelKey: "", contributions: () => [], + periodMemberUserIds: () => new Set(), disabled: false, startOpen: false, }) -const {title, panelKey, users, contributions, disabled, contributionPeriodId, startOpen} = toRefs(props) +const { + title, + panelKey, + users, + contributions, + periodMemberUserIds, + disabled, + contributionPeriodId, + startOpen, +} = toRefs(props) const emit = defineEmits<{ (e: "delete:contribution", userId: number): void diff --git a/services/frontend/src/components/common/rows/ContributionUserRow.vue b/services/frontend/src/components/common/rows/ContributionUserRow.vue index 58fbd491b..24ad34138 100644 --- a/services/frontend/src/components/common/rows/ContributionUserRow.vue +++ b/services/frontend/src/components/common/rows/ContributionUserRow.vue @@ -17,14 +17,13 @@ >
- {{ user.roles.includes("MEMBER") ? "Member" : "User" }} + {{ wasMemberDuringPeriod ? "Member" : "User" }} + periodMemberUserIds?: Set disabled?: boolean }>(), { contributions: () => [], + periodMemberUserIds: () => new Set(), disabled: false, }) @@ -66,6 +67,8 @@ const emit = defineEmits<{ const saving = ref(false) +const wasMemberDuringPeriod = computed(() => props.periodMemberUserIds.has(props.user.id)) + const contribution = computed(() => props.contributions.find( (c) => c.userId === props.user.id && c.contributionPeriodId === props.contributionPeriodId, diff --git a/services/frontend/src/pages/management/ContributionManager.vue b/services/frontend/src/pages/management/ContributionManager.vue index 318b79fa3..594681013 100644 --- a/services/frontend/src/pages/management/ContributionManager.vue +++ b/services/frontend/src/pages/management/ContributionManager.vue @@ -12,6 +12,7 @@ ([]) const contributions = ref([]) +const periodMemberUserIds = ref>(new Set()) const selectedPeriodId = ref(0) @@ -106,14 +110,25 @@ const contributionPeriodChanged = async (newPeriod?: ContributionPeriodResponse) if (!periodId) { selectedPeriodId.value = 0 contributions.value = [] + periodMemberUserIds.value = new Set() return } selectedPeriodId.value = periodId contributions.value = [] + periodMemberUserIds.value = new Set() - const resp = await findContributionsByPeriodId({path: {periodId}}) - if (selectedPeriodId.value === periodId) contributions.value = resp.data ?? [] + const [contributionsResp, membershipsResp] = await Promise.all([ + findContributionsByPeriodId({path: {periodId}}), + findMemberships({query: {from: newPeriod.startDate, to: newPeriod.endDate}}), + ]) + + if (selectedPeriodId.value !== periodId) return + + contributions.value = contributionsResp.data ?? [] + periodMemberUserIds.value = new Set( + (membershipsResp.data ?? []).map((membership) => membership.userId), + ) } onMounted(async () => { diff --git a/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts b/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts index 26d455501..8b0e55a0a 100644 --- a/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts +++ b/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts @@ -38,27 +38,46 @@ const vuetifyStubs = { } describe("ContributionUserList", () => { - it("renders list and forwards contribution update event", async () => { + it("renders list and forwards contribution update event and period membership set", async () => { const wrapper = mount(ContributionUserList, { props: { title: "Contribution paid", users, contributionPeriodId: 2, contributions: [], + periodMemberUserIds: new Set([1]), startOpen: true, }, global: { stubs: { ...vuetifyStubs, ContributionUserRow: { - template: "", + props: ["user", "periodMemberUserIds"], + template: ` + + `, }, }, }, }) expect(wrapper.text()).toContain("Contribution paid") - await wrapper.find("button").trigger("click") + + const rows = wrapper.findAll("button.row") + expect(rows).toHaveLength(2) + expect(rows[0].attributes("data-username")).toBe("lyndisluna") + expect(rows[0].attributes("data-is-period-member")).toBe("true") + expect(rows[1].attributes("data-username")).toBe("ariosfury") + expect(rows[1].attributes("data-is-period-member")).toBe("false") + + await rows[0].trigger("click") expect(wrapper.emitted("update:contribution")?.length).toBe(1) }) @@ -75,7 +94,7 @@ describe("ContributionUserList", () => { stubs: { ...vuetifyStubs, ContributionUserRow: { - props: ["user"], + props: ["user", "periodMemberUserIds"], template: "
{{ user.username }}
", }, }, diff --git a/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts b/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts index a0b671b07..9103080e2 100644 --- a/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts +++ b/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts @@ -12,6 +12,24 @@ vi.mock("@/services/api", () => ({ deleteContribution: mockDeleteContribution, })) +const mountRow = (props: Record) => + mount(ContributionUserRow, { + props: { + user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, + contributionPeriodId: 9, + contributions: [], + ...props, + }, + global: { + stubs: { + VChip: { + props: ["color"], + template: "", + }, + }, + }, + }) + describe("ContributionUserRow", () => { beforeEach(() => { vi.clearAllMocks() @@ -20,13 +38,7 @@ describe("ContributionUserRow", () => { }) it("marks and unmarks contribution", async () => { - const wrapper = mount(ContributionUserRow, { - props: { - user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, - contributionPeriodId: 9, - contributions: [], - }, - }) + const wrapper = mountRow({}) await (wrapper.vm as any).markPaid() expect(mockCreateContribution).toHaveBeenCalledWith({ @@ -48,4 +60,30 @@ describe("ContributionUserRow", () => { }, }) }) + + it("renders membership chip from selected-period membership set instead of current roles", () => { + const periodMember = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: ["USER"]}, + periodMemberUserIds: new Set([1]), + }) + + expect(periodMember.get("[data-testid='membership-chip']").text()).toBe("Member") + expect(periodMember.get("[data-testid='membership-chip']").attributes("data-color")).toBe("primary") + + const currentMemberOnly = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, + periodMemberUserIds: new Set(), + }) + + expect(currentMemberOnly.get("[data-testid='membership-chip']").text()).toBe("User") + expect(currentMemberOnly.get("[data-testid='membership-chip']").attributes("data-color")).toBe("grey") + + const noRoles = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: []}, + periodMemberUserIds: new Set(), + }) + + expect(noRoles.get("[data-testid='membership-chip']").text()).toBe("User") + expect(noRoles.get("[data-testid='membership-chip']").attributes("data-color")).toBe("grey") + }) }) diff --git a/services/frontend/tests/unit/pages/management/ContributionManager.test.ts b/services/frontend/tests/unit/pages/management/ContributionManager.test.ts index 5e8d5dbc0..8408f1706 100644 --- a/services/frontend/tests/unit/pages/management/ContributionManager.test.ts +++ b/services/frontend/tests/unit/pages/management/ContributionManager.test.ts @@ -5,14 +5,17 @@ import {settle} from "../helpers" const { mockFindUsers, + mockFindMemberships, mockFindContributionsByPeriodId, } = vi.hoisted(() => ({ mockFindUsers: vi.fn(), + mockFindMemberships: vi.fn(), mockFindContributionsByPeriodId: vi.fn(), })) vi.mock("@/services/api", () => ({ findUsers: mockFindUsers, + findMemberships: mockFindMemberships, findContributionsByPeriodId: mockFindContributionsByPeriodId, })) @@ -24,6 +27,33 @@ const contribution = (userId: number, contributionPeriodId: number, version = 1) version, }) +const membership = (userId: number, id = userId) => ({ + createdAt: "2026-01-01T00:00:00Z", + id, + incasso: false, + memberType: "REGULAR", + startDate: "2026-01-01", + updatedAt: "2026-01-01T00:00:00Z", + userId, + version: 1, +}) + +type ContributionsResponse = { + data: Array> +} + +type MembershipsResponse = { + data: Array> +} + +const deferred = () => { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((res) => { + resolve = res + }) + return {promise, resolve} +} + const period = (id: number) => ({ alumniFee: 15, createdAt: "2026-01-01T00:00:00Z", @@ -39,6 +69,9 @@ const period = (id: number) => ({ const usernames = (users: unknown) => (users as Array<{username: string}>).map((user) => user.username) +const memberUserIds = (list: VueWrapper) => + [...(list.props("periodMemberUserIds") as Set)].sort((a, b) => a - b) + const mountPage = () => shallowMount(ContributionManager, { global: { @@ -50,6 +83,7 @@ const mountPage = () => props: [ "contributionPeriodId", "contributions", + "periodMemberUserIds", "disabled", "panelKey", "users", @@ -84,6 +118,12 @@ describe("ContributionManager page", () => { mockFindContributionsByPeriodId.mockResolvedValue({ data: [contribution(1, 8)], }) + mockFindMemberships.mockResolvedValue({ + data: [ + membership(1, 90), + membership(3, 91), + ], + }) }) it("shows all users and splits them by selected-period contribution", async () => { @@ -104,6 +144,8 @@ describe("ContributionManager page", () => { expect(initialUnpaid.props("panelKey")).toBe("unpaid") expect(usernames(initialPaid.props("users"))).toEqual([]) expect(usernames(initialUnpaid.props("users"))).toEqual(["alice", "bob", "carol"]) + expect(memberUserIds(initialPaid)).toEqual([]) + expect(memberUserIds(initialUnpaid)).toEqual([]) expect(initialPaid.props("disabled")).toBe(true) expect(initialUnpaid.props("disabled")).toBe(true) @@ -114,13 +156,16 @@ describe("ContributionManager page", () => { const unpaid = contributionList(wrapper, "unpaid") expect(mockFindContributionsByPeriodId).toHaveBeenCalledWith({path: {periodId: 8}}) + expect(mockFindMemberships).toHaveBeenCalledWith({query: {from: "2026-01-01", to: "2026-12-31"}}) expect(usernames(paid.props("users"))).toEqual(["alice"]) expect(usernames(unpaid.props("users"))).toEqual(["bob", "carol"]) + expect(memberUserIds(paid)).toEqual([1, 3]) + expect(memberUserIds(unpaid)).toEqual([1, 3]) expect(paid.props("disabled")).toBe(false) expect(unpaid.props("disabled")).toBe(false) }) - it("clears contribution state and disables lists when selected period is undefined", async () => { + it("clears contribution and membership state and disables lists when selected period is undefined", async () => { const wrapper = mountPage() await settle() @@ -129,6 +174,7 @@ describe("ContributionManager page", () => { expect((wrapper.vm as any).selectedPeriodId).toBe(8) expect((wrapper.vm as any).contributions).toHaveLength(1) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([1, 3]) await (wrapper.vm as any).contributionPeriodChanged(undefined) await settle() @@ -140,10 +186,74 @@ describe("ContributionManager page", () => { expect((wrapper.vm as any).contributions).toEqual([]) expect(usernames(paid.props("users"))).toEqual([]) expect(usernames(unpaid.props("users"))).toEqual(["alice", "bob", "carol"]) + expect(memberUserIds(paid)).toEqual([]) + expect(memberUserIds(unpaid)).toEqual([]) expect(paid.props("disabled")).toBe(true) expect(unpaid.props("disabled")).toBe(true) }) + it("passes an empty period membership set when selected period has no memberships", async () => { + mockFindMemberships.mockResolvedValueOnce({data: []}) + + const wrapper = mountPage() + await settle() + + await (wrapper.vm as any).contributionPeriodChanged(period(8)) + await settle() + + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([]) + }) + + it("does not let stale period membership or contribution responses overwrite the current period", async () => { + const period8Contributions = deferred() + const period8Memberships = deferred() + const period9Contributions = deferred() + const period9Memberships = deferred() + const newPeriod = { + ...period(9), + startDate: "2027-01-01", + endDate: "2027-12-31", + } + + mockFindContributionsByPeriodId + .mockReturnValueOnce(period8Contributions.promise) + .mockReturnValueOnce(period9Contributions.promise) + mockFindMemberships + .mockReturnValueOnce(period8Memberships.promise) + .mockReturnValueOnce(period9Memberships.promise) + + const wrapper = mountPage() + await settle() + + const request8 = (wrapper.vm as any).contributionPeriodChanged(period(8)) + const request9 = (wrapper.vm as any).contributionPeriodChanged(newPeriod) + + period9Contributions.resolve({data: [contribution(2, 9)]}) + period9Memberships.resolve({data: [membership(2, 92)]}) + await request9 + await settle() + + expect(mockFindMemberships).toHaveBeenNthCalledWith(1, {query: {from: "2026-01-01", to: "2026-12-31"}}) + expect(mockFindMemberships).toHaveBeenNthCalledWith(2, {query: {from: "2027-01-01", to: "2027-12-31"}}) + expect((wrapper.vm as any).selectedPeriodId).toBe(9) + expect((wrapper.vm as any).contributions).toEqual([contribution(2, 9)]) + expect(usernames((wrapper.vm as any).usersPaid)).toEqual(["bob"]) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([2]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([2]) + + period8Contributions.resolve({data: [contribution(1, 8)]}) + period8Memberships.resolve({data: [membership(1, 91)]}) + await request8 + await settle() + + expect((wrapper.vm as any).selectedPeriodId).toBe(9) + expect((wrapper.vm as any).contributions).toEqual([contribution(2, 9)]) + expect(usernames((wrapper.vm as any).usersPaid)).toEqual(["bob"]) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([2]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([2]) + }) + it("upserts contributions by user and selected period", async () => { const wrapper = mountPage() await settle()