feat: enhance tenant management with status update functionality and UI improvements
This commit is contained in:
parent
80a477848c
commit
932d91abf5
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user