Skip to content

Commit 78cb0bb

Browse files
committed
implement suspend/reactivate for resellers
1 parent b2318a1 commit 78cb0bb

4 files changed

Lines changed: 286 additions & 4 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<!--
2+
Copyright (C) 2026 Nethesis S.r.l.
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import { NeInlineNotification } from '@nethesis/vue-components'
8+
import { NeModal } from '@nethesis/vue-components'
9+
import { useI18n } from 'vue-i18n'
10+
import { useMutation, useQueryCache } from '@pinia/colada'
11+
import {
12+
reactivateReseller,
13+
RESELLERS_KEY,
14+
RESELLERS_TOTAL_KEY,
15+
type Reseller,
16+
} from '@/lib/resellers'
17+
import { useNotificationsStore } from '@/stores/notifications'
18+
19+
const { visible = false, reseller = undefined } = defineProps<{
20+
visible: boolean
21+
reseller: Reseller | undefined
22+
}>()
23+
24+
const emit = defineEmits(['close'])
25+
26+
const { t } = useI18n()
27+
const notificationsStore = useNotificationsStore()
28+
const queryCache = useQueryCache()
29+
30+
const {
31+
mutate: reactivateResellerMutate,
32+
isLoading: reactivateResellerLoading,
33+
reset: reactivateResellerReset,
34+
error: reactivateResellerError,
35+
} = useMutation({
36+
mutation: (reseller: Reseller) => {
37+
return reactivateReseller(reseller)
38+
},
39+
onSuccess(data, vars) {
40+
// show success notification after modal closes
41+
setTimeout(() => {
42+
notificationsStore.createNotification({
43+
kind: 'success',
44+
title: t('organizations.organization_reactivated'),
45+
description: t('organizations.organization_reactivated_successfully', {
46+
name: vars.name,
47+
}),
48+
})
49+
}, 500)
50+
51+
emit('close')
52+
},
53+
onError: (error) => {
54+
console.error('Error reactivating reseller:', error)
55+
},
56+
onSettled: () => {
57+
queryCache.invalidateQueries({ key: [RESELLERS_KEY] })
58+
queryCache.invalidateQueries({ key: [RESELLERS_TOTAL_KEY] })
59+
},
60+
})
61+
62+
function onShow() {
63+
// clear error
64+
reactivateResellerReset()
65+
}
66+
</script>
67+
68+
<template>
69+
<NeModal
70+
:visible="visible"
71+
:title="$t('organizations.reactivate_organization')"
72+
kind="warning"
73+
:primary-label="$t('common.reactivate')"
74+
:cancel-label="$t('common.cancel')"
75+
:primary-button-disabled="reactivateResellerLoading"
76+
:primary-button-loading="reactivateResellerLoading"
77+
:close-aria-label="$t('common.close')"
78+
@close="emit('close')"
79+
@primary-click="reactivateResellerMutate(reseller!)"
80+
@show="onShow"
81+
>
82+
<p>
83+
{{ t('organizations.reactivate_organization_confirmation', { name: reseller?.name }) }}
84+
</p>
85+
<NeInlineNotification
86+
v-if="reactivateResellerError?.message"
87+
kind="error"
88+
:title="t('organizations.cannot_reactivate_organization')"
89+
:description="reactivateResellerError.message"
90+
class="mt-4"
91+
/>
92+
</NeModal>
93+
</template>

frontend/src/components/resellers/ResellersTable.vue

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
faCity,
1212
faPenToSquare,
1313
faTrash,
14+
faCirclePause,
15+
faCirclePlay,
16+
faCircleCheck,
1417
} from '@fortawesome/free-solid-svg-icons'
1518
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
1619
import {
@@ -34,6 +37,8 @@ import { computed, ref, watch } from 'vue'
3437
import CreateOrEditResellerDrawer from './CreateOrEditResellerDrawer.vue'
3538
import { useI18n } from 'vue-i18n'
3639
import DeleteResellerModal from './DeleteResellerModal.vue'
40+
import SuspendResellerModal from './SuspendResellerModal.vue'
41+
import ReactivateResellerModal from './ReactivateResellerModal.vue'
3742
import { savePageSizeToStorage } from '@/lib/tablePageSize'
3843
import { useResellers } from '@/queries/resellers'
3944
import { canManageResellers } from '@/lib/permissions'
@@ -59,6 +64,8 @@ const {
5964
const currentReseller = ref<Reseller | undefined>()
6065
const isShownCreateOrEditResellerDrawer = ref(false)
6166
const isShownDeleteResellerDrawer = ref(false)
67+
const isShownSuspendResellerModal = ref(false)
68+
const isShownReactivateResellerModal = ref(false)
6269
6370
const resellersPage = computed(() => {
6471
return state.value.data?.resellers
@@ -111,22 +118,53 @@ function showDeleteResellerDrawer(reseller: Reseller) {
111118
isShownDeleteResellerDrawer.value = true
112119
}
113120
121+
function showSuspendResellerModal(reseller: Reseller) {
122+
currentReseller.value = reseller
123+
isShownSuspendResellerModal.value = true
124+
}
125+
126+
function showReactivateResellerModal(reseller: Reseller) {
127+
currentReseller.value = reseller
128+
isShownReactivateResellerModal.value = true
129+
}
130+
114131
function onCloseDrawer() {
115132
isShownCreateOrEditResellerDrawer.value = false
116133
emit('close-drawer')
117134
}
118135
119136
function getKebabMenuItems(reseller: Reseller) {
120-
return [
121-
{
137+
const items = []
138+
139+
if (canManageResellers()) {
140+
if (reseller.suspended_at) {
141+
items.push({
142+
id: 'reactivateReseller',
143+
label: t('common.reactivate'),
144+
icon: faCirclePlay,
145+
action: () => showReactivateResellerModal(reseller),
146+
disabled: asyncStatus.value === 'loading',
147+
})
148+
} else {
149+
items.push({
150+
id: 'suspendReseller',
151+
label: t('common.suspend'),
152+
icon: faCirclePause,
153+
action: () => showSuspendResellerModal(reseller),
154+
disabled: asyncStatus.value === 'loading',
155+
})
156+
}
157+
158+
items.push({
122159
id: 'deleteReseller',
123160
label: t('common.delete'),
124161
icon: faTrash,
125162
danger: true,
126163
action: () => showDeleteResellerDrawer(reseller),
127164
disabled: asyncStatus.value === 'loading',
128-
},
129-
]
165+
})
166+
}
167+
return items
130168
}
131169
132170
const onSort = (payload: SortEvent) => {
@@ -186,6 +224,7 @@ const onSort = (payload: SortEvent) => {
186224
:options="[
187225
{ id: 'name', label: t('organizations.name') },
188226
{ id: 'description', label: t('organizations.description') },
227+
{ id: 'suspended_at', label: t('common.status') },
189228
]"
190229
:open-menu-aria-label="t('ne_dropdown.open_menu')"
191230
:sort-by-label="t('sort.sort_by')"
@@ -233,6 +272,9 @@ const onSort = (payload: SortEvent) => {
233272
<NeTableHeadCell sortable column-key="description" @sort="onSort">{{
234273
$t('organizations.description')
235274
}}</NeTableHeadCell>
275+
<NeTableHeadCell sortable column-key="suspended_at" @sort="onSort">{{
276+
$t('common.status')
277+
}}</NeTableHeadCell>
236278
<NeTableHeadCell>
237279
<!-- no header for actions -->
238280
</NeTableHeadCell>
@@ -245,6 +287,30 @@ const onSort = (payload: SortEvent) => {
245287
<NeTableCell :data-label="$t('organizations.description')">
246288
{{ item.description || '-' }}
247289
</NeTableCell>
290+
<NeTableCell :data-label="$t('common.status')">
291+
<div class="flex items-center gap-2">
292+
<template v-if="item.suspended_at">
293+
<FontAwesomeIcon
294+
:icon="faCirclePause"
295+
class="size-4 text-gray-700 dark:text-gray-400"
296+
aria-hidden="true"
297+
/>
298+
<span>
299+
{{ t('common.suspended') }}
300+
</span>
301+
</template>
302+
<template v-else>
303+
<FontAwesomeIcon
304+
:icon="faCircleCheck"
305+
class="size-4 text-green-600 dark:text-green-400"
306+
aria-hidden="true"
307+
/>
308+
<span>
309+
{{ t('common.enabled') }}
310+
</span>
311+
</template>
312+
</div>
313+
</NeTableCell>
248314
<NeTableCell :data-label="$t('common.actions')">
249315
<div v-if="canManageResellers()" class="-ml-2.5 flex gap-2 xl:ml-0 xl:justify-end">
250316
<NeButton
@@ -301,5 +367,17 @@ const onSort = (payload: SortEvent) => {
301367
:reseller="currentReseller"
302368
@close="isShownDeleteResellerDrawer = false"
303369
/>
370+
<!-- suspend reseller modal -->
371+
<SuspendResellerModal
372+
:visible="isShownSuspendResellerModal"
373+
:reseller="currentReseller"
374+
@close="isShownSuspendResellerModal = false"
375+
/>
376+
<!-- reactivate reseller modal -->
377+
<ReactivateResellerModal
378+
:visible="isShownReactivateResellerModal"
379+
:reseller="currentReseller"
380+
@close="isShownReactivateResellerModal = false"
381+
/>
304382
</div>
305383
</template>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<!--
2+
Copyright (C) 2026 Nethesis S.r.l.
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import { NeInlineNotification } from '@nethesis/vue-components'
8+
import { NeModal } from '@nethesis/vue-components'
9+
import { useI18n } from 'vue-i18n'
10+
import { useMutation, useQueryCache } from '@pinia/colada'
11+
import {
12+
suspendReseller,
13+
RESELLERS_KEY,
14+
RESELLERS_TOTAL_KEY,
15+
type Reseller,
16+
} from '@/lib/resellers'
17+
import { useNotificationsStore } from '@/stores/notifications'
18+
19+
const { visible = false, reseller = undefined } = defineProps<{
20+
visible: boolean
21+
reseller: Reseller | undefined
22+
}>()
23+
24+
const emit = defineEmits(['close'])
25+
26+
const { t } = useI18n()
27+
const notificationsStore = useNotificationsStore()
28+
const queryCache = useQueryCache()
29+
30+
const {
31+
mutate: suspendResellerMutate,
32+
isLoading: suspendResellerLoading,
33+
reset: suspendResellerReset,
34+
error: suspendResellerError,
35+
} = useMutation({
36+
mutation: (reseller: Reseller) => {
37+
return suspendReseller(reseller)
38+
},
39+
onSuccess(data, vars) {
40+
// show success notification after modal closes
41+
setTimeout(() => {
42+
notificationsStore.createNotification({
43+
kind: 'success',
44+
title: t('organizations.organization_suspended'),
45+
description: t('organizations.organization_suspended_successfully', {
46+
name: vars.name,
47+
}),
48+
})
49+
}, 500)
50+
51+
emit('close')
52+
},
53+
onError: (error) => {
54+
console.error('Error suspending reseller:', error)
55+
},
56+
onSettled: () => {
57+
queryCache.invalidateQueries({ key: [RESELLERS_KEY] })
58+
queryCache.invalidateQueries({ key: [RESELLERS_TOTAL_KEY] })
59+
},
60+
})
61+
62+
function onShow() {
63+
// clear error
64+
suspendResellerReset()
65+
}
66+
</script>
67+
68+
<template>
69+
<NeModal
70+
:visible="visible"
71+
:title="$t('organizations.suspend_organization')"
72+
kind="warning"
73+
:primary-label="$t('common.suspend')"
74+
:cancel-label="$t('common.cancel')"
75+
primary-button-kind="danger"
76+
:primary-button-disabled="suspendResellerLoading"
77+
:primary-button-loading="suspendResellerLoading"
78+
:close-aria-label="$t('common.close')"
79+
@close="emit('close')"
80+
@primary-click="suspendResellerMutate(reseller!)"
81+
@show="onShow"
82+
>
83+
<p>
84+
{{ t('organizations.suspend_organization_confirmation', { name: reseller?.name }) }}
85+
</p>
86+
<NeInlineNotification
87+
v-if="suspendResellerError?.message"
88+
kind="error"
89+
:title="t('organizations.cannot_suspend_organization')"
90+
:description="suspendResellerError.message"
91+
class="mt-4"
92+
/>
93+
</NeModal>
94+
</template>

frontend/src/lib/resellers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const CreateResellerSchema = v.object({
2323
export const ResellerSchema = v.object({
2424
...CreateResellerSchema.entries,
2525
logto_id: v.string(),
26+
suspended_at: v.optional(v.string()),
2627
})
2728

2829
export type CreateReseller = v.InferOutput<typeof CreateResellerSchema>
@@ -87,3 +88,19 @@ export const getResellersTotal = () => {
8788
})
8889
.then((res) => res.data.data.total as number)
8990
}
91+
92+
export const suspendReseller = (reseller: Reseller) => {
93+
const loginStore = useLoginStore()
94+
95+
return axios.patch(`${API_URL}/resellers/${reseller.logto_id}/suspend`, {}, {
96+
headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
97+
})
98+
}
99+
100+
export const reactivateReseller = (reseller: Reseller) => {
101+
const loginStore = useLoginStore()
102+
103+
return axios.patch(`${API_URL}/resellers/${reseller.logto_id}/reactivate`, {}, {
104+
headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
105+
})
106+
}

0 commit comments

Comments
 (0)