feat: enhance tenant management with status update functionality and UI improvements

This commit is contained in:
Yoga Pangestu 2026-09-05 09:44:27 +07:00
parent 80a477848c
commit 932d91abf5

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Check, ChevronDown, Info, Loader2, X } from '@lucide/vue'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { ApiError, apiFetch } from '@/lib/api'
import DataTable, { type DataTableColumn } from '@shared/components/DataTable.vue'
import DeleteConfirmModal from '@shared/components/DeleteConfirmModal.vue'
@ -83,15 +84,14 @@ const {
const isFormOpen = ref(false)
const isSubmitting = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({ name: '', address: '', phone: '', timezone: 'Asia/Jakarta', status: 'trial' })
const errors = reactive<{ name?: string; address?: string; phone?: string; timezone?: string; status?: string }>({})
const form = reactive({ name: '', address: '', phone: '', timezone: 'Asia/Jakarta' })
const errors = reactive<{ name?: string; address?: string; phone?: string; timezone?: string }>({})
function resetErrors() {
errors.name = undefined
errors.address = undefined
errors.phone = undefined
errors.timezone = undefined
errors.status = undefined
}
function openEditForm(tenant: Tenant) {
@ -100,7 +100,6 @@ function openEditForm(tenant: Tenant) {
form.address = tenant.address ?? ''
form.phone = tenant.phone ?? ''
form.timezone = tenant.timezone
form.status = tenant.status
resetErrors()
isFormOpen.value = true
}
@ -114,7 +113,6 @@ async function handleSubmit() {
address: form.address || null,
phone: form.phone || null,
timezone: form.timezone,
status: form.status,
}
try {
@ -132,7 +130,6 @@ async function handleSubmit() {
errors.address = error.errors.address
errors.phone = error.errors.phone
errors.timezone = error.errors.timezone
errors.status = error.errors.status
} else if (error instanceof ApiError) {
toast.error(error.message)
} else {
@ -143,6 +140,61 @@ async function handleSubmit() {
}
}
const statusMenuTenant = ref<Tenant | null>(null)
const statusMenuPos = ref({ top: 0, left: 0 })
const updatingStatusId = ref<string | null>(null)
function toggleStatusMenu(event: MouseEvent, tenant: Tenant) {
if (statusMenuTenant.value?.id === tenant.id) {
statusMenuTenant.value = null
return
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
statusMenuPos.value = { top: rect.bottom + 4, left: rect.left }
statusMenuTenant.value = tenant
}
async function selectStatus(tenant: Tenant, status: string) {
statusMenuTenant.value = null
if (tenant.status === status) return
updatingStatusId.value = tenant.id
try {
const response = await apiFetch<ItemResponse<Tenant>>(`/v1/tenants/${tenant.id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
})
tenant.status = response.data.status
toast.success(response.message)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Gagal memperbarui status tenant.')
} finally {
updatingStatusId.value = null
}
}
function handleClickOutsideStatusMenu(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('[data-status-menu]') || target.closest('[data-status-trigger]')) return
statusMenuTenant.value = null
}
onMounted(() => document.addEventListener('click', handleClickOutsideStatusMenu))
onUnmounted(() => document.removeEventListener('click', handleClickOutsideStatusMenu))
const STATUS_TIP_KEY = 'profitra_admin_tenant_status_tip_dismissed'
const showStatusTip = ref(localStorage.getItem(STATUS_TIP_KEY) !== '1')
function dismissStatusTip() {
showStatusTip.value = false
localStorage.setItem(STATUS_TIP_KEY, '1')
}
function tenantUrl(subdomain: string) {
const base = new URL(import.meta.env.VITE_PROFITRA_URL)
return `${base.protocol}//${subdomain}.${base.host}`
}
const {
target: deletingTenant,
isDeleting,
@ -179,6 +231,16 @@ function formatDate(value: string) {
<div>
<PageHeader title="Tenant" description="Kelola tenant yang terdaftar di sistem." hide-action />
<div v-if="showStatusTip" class="mt-4 flex items-start gap-3 rounded-lg border border-action/30 bg-action/5 p-3.5">
<Info :size="18" class="mt-0.5 shrink-0 text-action" />
<p class="flex-1 text-sm text-secondary">
<span class="font-medium text-primary">Tips:</span> klik badge status pada tabel untuk mengubah status tenant secara langsung, tanpa perlu membuka form ubah.
</p>
<button type="button" class="text-secondary hover:text-primary" aria-label="Tutup" @click="dismissStatusTip">
<X :size="16" />
</button>
</div>
<DataTable class="mt-4" :columns="columns" :items="tenants" :loading="isLoading" :page="page" :last-page="lastPage"
:per-page="perPage" :total="total" :sort="sort ?? undefined" empty-text="Belum ada data."
:has-active-filters="hasActiveFilters"
@ -198,7 +260,10 @@ function formatDate(value: string) {
<template #cell-name="{ item }">
<div>
<p class="font-medium text-primary">{{ (item as unknown as Tenant).name }}</p>
<p class="text-xs text-secondary">{{ (item as unknown as Tenant).subdomain }}.profitra.id</p>
<a :href="tenantUrl((item as unknown as Tenant).subdomain)" target="_blank" rel="noopener noreferrer"
class="text-xs text-action hover:underline">
{{ (item as unknown as Tenant).subdomain }}.profitra.id
</a>
</div>
</template>
@ -215,10 +280,14 @@ function formatDate(value: string) {
</template>
<template #cell-status="{ item }">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="statusBadgeClass((item as unknown as Tenant).status)">
<button type="button" data-status-trigger :disabled="updatingStatusId === (item as unknown as Tenant).id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80 disabled:opacity-50"
:class="statusBadgeClass((item as unknown as Tenant).status)"
@click="toggleStatusMenu($event, item as unknown as Tenant)">
{{ statusLabel((item as unknown as Tenant).status) }}
</span>
<Loader2 v-if="updatingStatusId === (item as unknown as Tenant).id" :size="12" class="animate-spin" />
<ChevronDown v-else :size="12" />
</button>
</template>
<template #cell-trial_ends_at="{ item }">
@ -265,12 +334,6 @@ function formatDate(value: string) {
:class="errors.timezone ? 'border-danger focus:ring-danger' : 'border-border focus:ring-action'" />
<p v-if="errors.timezone" class="mt-1.5 text-xs text-danger">{{ errors.timezone }}</p>
</div>
<div>
<label class="text-sm font-medium text-primary">Status</label>
<Select v-model="form.status" :options="statusOptions" class="mt-1.5" />
<p v-if="errors.status" class="mt-1.5 text-xs text-danger">{{ errors.status }}</p>
</div>
</form>
<template #footer>
@ -287,5 +350,19 @@ function formatDate(value: string) {
<DeleteConfirmModal :open="!!deletingTenant" title="Hapus Tenant" :item-name="deletingTenant?.name || ''"
:loading="isDeleting" @close="deletingTenant = null" @confirm="handleDelete" />
<Teleport to="body">
<ul v-if="statusMenuTenant" data-status-menu role="listbox"
class="fixed z-[60] w-44 overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lg"
:style="{ top: `${statusMenuPos.top}px`, left: `${statusMenuPos.left}px` }">
<li v-for="option in statusOptions" :key="option.value" role="option"
class="flex cursor-pointer items-center justify-between gap-4 px-3 py-2 text-sm hover:bg-bg"
:class="option.value === statusMenuTenant.status ? 'font-medium text-action' : 'text-primary'"
@click="selectStatus(statusMenuTenant, String(option.value))">
{{ option.label }}
<Check v-if="option.value === statusMenuTenant.status" :size="14" />
</li>
</ul>
</Teleport>
</div>
</template>