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
70 changes: 33 additions & 37 deletions services/frontend/src/pages/management/ContributionManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
:contributions="contributions"
:disabled="!selectedPeriodId"
panel-key="paid"
:users="membersPaid"
:users="usersPaid"
class="mt-3"
title="Contribution paid"
@update:contribution="contributionAddedOrUpdated"
Expand All @@ -26,7 +26,7 @@
:contributions="contributions"
:disabled="!selectedPeriodId"
panel-key="unpaid"
:users="membersUnpaid"
:users="usersUnpaid"
class="mt-3"
title="Contribution unpaid"
@update:contribution="contributionAddedOrUpdated"
Expand All @@ -38,7 +38,7 @@
</template>

<script lang="ts" setup>
import {onMounted, ref, watch} from "vue"
import {computed, onMounted, ref} from "vue"
import TopBanner from "@/components/common/banners/TopBanner.vue"
import ContributionPeriodList from "@/components/common/lists/ContributionPeriodList.vue"
import ContributionUserList from "@/components/common/lists/ContributionUserList.vue"
Expand All @@ -47,20 +47,13 @@ import {
type ContributionPeriodResponse,
type ContributionResponse,
findContributionsByPeriodId,
findMemberships,
findUsers,
type MembershipResponse,
type UserDetailResponse,
} from "@/services/api"

const users = ref<UserDetailResponse[]>([])
const memberships = ref<MembershipResponse[]>([])
const contributions = ref<ContributionResponse[]>([])

const membersPaid = ref<UserDetailResponse[]>([])
const membersUnpaid = ref<UserDetailResponse[]>([])

const contributionPeriod = ref<ContributionPeriodResponse | undefined>()
const selectedPeriodId = ref<number>(0)

if ("scrollRestoration" in globalThis.history) {
Expand All @@ -73,30 +66,26 @@ const getUsers = async () => {
else console.log(response.error)
}

const getMemberships = async () => {
try {
const response = await findMemberships()
memberships.value = response.data ?? []
} catch (error) {
console.error("Error fetching memberships:", error)
}
}

const hasActiveMembership = (userId: number) =>
memberships.value.some((membership) => membership.userId === userId && !membership.endDate)
const paidUserIds = computed<Set<number>>(() => {
const periodId = selectedPeriodId.value
if (!periodId) return new Set<number>()

const hasContribution = (userId: number) =>
contributions.value.some(
(c) => c.userId === userId && c.contributionPeriodId === selectedPeriodId.value,
return new Set(
contributions.value
.filter((contribution) => contribution.contributionPeriodId === periodId)
.map((contribution) => contribution.userId),
)
})

const updateLists = () => {
const all = users.value.filter((u) => hasActiveMembership(u.id))
membersPaid.value = all.filter((u) => hasContribution(u.id!))
membersUnpaid.value = all.filter((u) => !hasContribution(u.id!))
}
const hasSelectedPeriodContribution = (user: UserDetailResponse) => paidUserIds.value.has(user.id)

const usersPaid = computed<UserDetailResponse[]>(() =>
users.value.filter(hasSelectedPeriodContribution),
)

watch([contributions, memberships, users, selectedPeriodId], updateLists, {deep: true})
const usersUnpaid = computed<UserDetailResponse[]>(() =>
users.value.filter((user) => !hasSelectedPeriodContribution(user)),
)

const contributionAddedOrUpdated = (updated: ContributionResponse) => {
const idx = contributions.value.findIndex(
Expand All @@ -112,17 +101,24 @@ const contributionDeleted = (userId: number) => {
)
}

const contributionPeriodChanged = async (newPeriod: ContributionPeriodResponse) => {
if (!newPeriod) return
contributionPeriod.value = newPeriod
selectedPeriodId.value = newPeriod.id as number
const resp = await findContributionsByPeriodId({path: {periodId: newPeriod.id as number}})
contributions.value = resp.data ?? []
const contributionPeriodChanged = async (newPeriod?: ContributionPeriodResponse) => {
const periodId = newPeriod?.id
if (!periodId) {
selectedPeriodId.value = 0
contributions.value = []
return
}

selectedPeriodId.value = periodId
contributions.value = []

const resp = await findContributionsByPeriodId({path: {periodId}})
if (selectedPeriodId.value === periodId) contributions.value = resp.data ?? []
}

onMounted(async () => {
try {
await Promise.all([getUsers(), getMemberships()])
await getUsers()
} catch (error) {
console.error("Error fetching data:", error)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,69 +1,170 @@
import {beforeEach, describe, expect, it, vi} from "vitest"
import {shallowMount} from "@vue/test-utils"
import {shallowMount, type VueWrapper} from "@vue/test-utils"
import ContributionManager from "@/pages/management/ContributionManager.vue"
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,
}))

const contribution = (userId: number, contributionPeriodId: number, version = 1) => ({
contributionPeriodId,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
userId,
version,
})

const period = (id: number) => ({
alumniFee: 15,
createdAt: "2026-01-01T00:00:00Z",
endDate: "2026-12-31",
fullYearFee: 30,
halfYearFee: 15,
id,
startDate: "2026-01-01",
updatedAt: "2026-01-01T00:00:00Z",
version: 1,
})

const usernames = (users: unknown) =>
(users as Array<{username: string}>).map((user) => user.username)

const mountPage = () =>
shallowMount(ContributionManager, {
global: {
stubs: {
TopBanner: true,
ContributionPeriodList: true,
ContributionUserList: {
name: "ContributionUserList",
props: [
"contributionPeriodId",
"contributions",
"disabled",
"panelKey",
"users",
"title",
],
template: "<div />",
},
},
},
})

const contributionList = (wrapper: VueWrapper, panelKey: string) => {
const list = wrapper.findAllComponents({name: "ContributionUserList"})
.find((component) => component.props("panelKey") === panelKey)
if (!list) throw new Error(`ContributionUserList ${panelKey} was not rendered`)
return list
}

describe("ContributionManager page", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindUsers.mockResolvedValue({
status: 200,
data: {
content: [
{id: 1, username: "alice"},
{id: 2, username: "bob"},
{id: 1, username: "alice", fullName: "Alice Member", roles: ["MEMBER"]},
{id: 2, username: "bob", fullName: "Bob Guest", roles: ["GUEST"]},
{id: 3, username: "carol", fullName: "Carol Member", roles: ["MEMBER"]},
],
},
})
mockFindMemberships.mockResolvedValue({
mockFindContributionsByPeriodId.mockResolvedValue({
data: [contribution(1, 8)],
})
})

it("shows all users and splits them by selected-period contribution", async () => {
mockFindContributionsByPeriodId.mockResolvedValueOnce({
data: [
{id: 11, userId: 1, endDate: null},
{id: 12, userId: 2, endDate: null},
contribution(1, 8),
contribution(2, 7),
],
})
mockFindContributionsByPeriodId.mockResolvedValue({
data: [{id: 91, userId: 1, contributionPeriodId: 8}],
})

const wrapper = mountPage()
await settle()

const initialPaid = contributionList(wrapper, "paid")
const initialUnpaid = contributionList(wrapper, "unpaid")

expect(initialPaid.props("panelKey")).toBe("paid")
expect(initialUnpaid.props("panelKey")).toBe("unpaid")
expect(usernames(initialPaid.props("users"))).toEqual([])
expect(usernames(initialUnpaid.props("users"))).toEqual(["alice", "bob", "carol"])
expect(initialPaid.props("disabled")).toBe(true)
expect(initialUnpaid.props("disabled")).toBe(true)

await (wrapper.vm as any).contributionPeriodChanged(period(8))
await settle()

const paid = contributionList(wrapper, "paid")
const unpaid = contributionList(wrapper, "unpaid")

expect(mockFindContributionsByPeriodId).toHaveBeenCalledWith({path: {periodId: 8}})
expect(usernames(paid.props("users"))).toEqual(["alice"])
expect(usernames(unpaid.props("users"))).toEqual(["bob", "carol"])
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 () => {
const wrapper = mountPage()
await settle()

await (wrapper.vm as any).contributionPeriodChanged(period(8))
await settle()

expect((wrapper.vm as any).selectedPeriodId).toBe(8)
expect((wrapper.vm as any).contributions).toHaveLength(1)

await (wrapper.vm as any).contributionPeriodChanged(undefined)
await settle()

const paid = contributionList(wrapper, "paid")
const unpaid = contributionList(wrapper, "unpaid")

expect((wrapper.vm as any).selectedPeriodId).toBe(0)
expect((wrapper.vm as any).contributions).toEqual([])
expect(usernames(paid.props("users"))).toEqual([])
expect(usernames(unpaid.props("users"))).toEqual(["alice", "bob", "carol"])
expect(paid.props("disabled")).toBe(true)
expect(unpaid.props("disabled")).toBe(true)
})

it("upserts contributions by user and selected period", async () => {
const wrapper = shallowMount(ContributionManager, {
global: {
stubs: {
ContributionPeriodList: true,
ContributionUserList: true,
},
},
})
const wrapper = mountPage()
await settle()

await (wrapper.vm as any).contributionPeriodChanged(period(8))
await settle()

await (wrapper.vm as any).contributionPeriodChanged({id: 8, startDate: "2026-01-01", endDate: "2026-12-31"})
expect(mockFindContributionsByPeriodId).toHaveBeenCalledWith({path: {periodId: 8}})
expect(usernames((wrapper.vm as any).usersPaid)).toEqual(["alice"])
expect(usernames((wrapper.vm as any).usersUnpaid)).toEqual(["bob", "carol"])

;(wrapper.vm as any).contributionAddedOrUpdated({id: 91, userId: 1, contributionPeriodId: 8})
;(wrapper.vm as any).contributionAddedOrUpdated({id: 92, userId: 1, contributionPeriodId: 8})
;(wrapper.vm as any).contributionAddedOrUpdated(contribution(1, 8, 1))
;(wrapper.vm as any).contributionAddedOrUpdated(contribution(1, 8, 2))

expect((wrapper.vm as any).contributions).toHaveLength(1)
expect((wrapper.vm as any).contributions[0].id).toBe(92)
expect((wrapper.vm as any).contributions[0].version).toBe(2)

;(wrapper.vm as any).contributionDeleted(1)

expect((wrapper.vm as any).contributions).toHaveLength(0)
expect(usernames((wrapper.vm as any).usersPaid)).toEqual([])
expect(usernames((wrapper.vm as any).usersUnpaid)).toEqual(["alice", "bob", "carol"])
})
})
Loading