Files
InvoiceShelf/resources/scripts-v2/features/company/modules/views/ModuleDetailView.vue
Darko Gjorgjijoski d91f6ff2e3 Phase 4b: Remaining features — payments, expenses, recurring
invoices, members, reports, settings, customer portal, modules,
installation

82 files, 14293 lines. Completes all feature modules:
- payments: CRUD with send/preview, payment modes
- expenses: CRUD with receipt upload, categories
- recurring-invoices: full frequency logic, limit by date/count
- members: list with roles, invite modal, pending invitations
- reports: sales, profit/loss, expenses, tax with date ranges
- settings: 14 settings views, number customizer, mail config
- customer-portal: consolidated store, 8 views, portal layout
- modules: marketplace index, detail/install, module cards
- installation: 8-step wizard with requirements/db/mail/account

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:30:00 +02:00

517 lines
17 KiB
Vue

<template>
<div v-if="isFetchingInitialData" class="p-8">
<div class="animate-pulse space-y-4">
<div class="h-8 bg-surface-tertiary rounded w-1/3" />
<div class="h-64 bg-surface-tertiary rounded" />
</div>
</div>
<BasePage v-else-if="moduleData" class="bg-surface">
<BasePageHeader :title="moduleData.name">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/modules" />
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<div class="lg:grid lg:grid-rows-1 lg:grid-cols-7 lg:gap-x-8 lg:gap-y-10 xl:gap-x-16 mt-6">
<!-- Image Gallery -->
<div class="lg:row-end-1 lg:col-span-4">
<div class="flex flex-col-reverse">
<!-- Thumbnails -->
<div class="hidden mt-6 w-full max-w-2xl mx-auto sm:block lg:max-w-none">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-6" role="tablist">
<button
v-if="thumbnail && videoUrl"
:class="[
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
{ 'outline-hidden ring-3 ring-offset-1 ring-primary-500': displayVideo },
]"
type="button"
@click="setDisplayVideo"
>
<span class="absolute inset-0 rounded-md overflow-hidden">
<img :src="thumbnail" alt="" class="w-full h-full object-center object-cover" />
</span>
</button>
<button
v-for="(screenshot, ssIdx) in displayImages"
:key="ssIdx"
:class="[
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
{ 'outline-hidden ring-3 ring-offset-1 ring-primary-500': displayImage === screenshot.url },
]"
type="button"
@click="setDisplayImage(screenshot.url)"
>
<span class="absolute inset-0 rounded-md overflow-hidden">
<img :src="screenshot.url" alt="" class="w-full h-full object-center object-cover" />
</span>
</button>
</div>
</div>
<!-- Video -->
<div v-if="displayVideo" class="aspect-w-4 aspect-h-3">
<iframe
:src="videoUrl ?? ''"
class="sm:rounded-lg"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</div>
<!-- Main Image -->
<div
v-else
class="aspect-w-4 aspect-h-3 rounded-lg bg-surface-tertiary overflow-hidden"
>
<img
:src="displayImage ?? ''"
alt="Module Images"
class="w-full h-full object-center object-cover sm:rounded-lg"
/>
</div>
</div>
</div>
<!-- Details -->
<div class="max-w-2xl mx-auto mt-10 lg:max-w-none lg:mt-0 lg:row-end-2 lg:row-span-2 lg:col-span-3 w-full">
<!-- Rating -->
<div class="flex items-center">
<BaseRating :rating="averageRating" />
</div>
<!-- Name & Version -->
<div class="flex flex-col-reverse">
<div class="mt-4">
<h1 class="text-2xl font-extrabold tracking-tight text-heading sm:text-3xl">
{{ moduleData.name }}
</h1>
<p v-if="moduleData.latest_module_version" class="text-sm text-muted mt-2">
{{ $t('modules.version') }}
{{ moduleVersion }} ({{ $t('modules.last_updated') }}
{{ updatedAt }})
</p>
</div>
</div>
<!-- Description -->
<div
class="prose prose-sm max-w-none text-muted text-sm my-10"
v-html="moduleData.long_description"
/>
<!-- Action Buttons -->
<div v-if="!moduleData.purchased">
<a
:href="buyLink"
target="_blank"
class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
>
<BaseButton size="xl" class="items-center flex justify-center text-base mt-10">
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
{{ $t('modules.buy_now') }}
</BaseButton>
</a>
</div>
<div v-else>
<!-- Not installed yet -->
<div v-if="!moduleData.installed" class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<BaseButton
v-if="moduleData.latest_module_version"
size="xl"
variant="primary-outline"
:loading="isInstalling"
:disabled="isInstalling"
class="mr-4 flex items-center justify-center text-base"
@click="handleInstall"
>
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2" />
{{ $t('modules.install') }}
</BaseButton>
</div>
<!-- Already installed -->
<div v-else-if="isModuleInstalled" class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<BaseButton
v-if="moduleData.update_available"
variant="primary"
size="xl"
:loading="isInstalling"
:disabled="isInstalling"
class="mr-4 flex items-center justify-center text-base"
@click="handleInstall"
>
{{ $t('modules.update_to') }}
<span class="ml-2">{{ moduleData.latest_module_version }}</span>
</BaseButton>
<BaseButton
v-if="moduleData.enabled"
variant="danger"
size="xl"
:loading="isDisabling"
:disabled="isDisabling"
class="mr-4 flex items-center justify-center text-base"
@click="handleDisable"
>
<BaseIcon v-if="!isDisabling" name="NoSymbolIcon" class="mr-2" />
{{ $t('modules.disable') }}
</BaseButton>
<BaseButton
v-else
variant="primary-outline"
size="xl"
:loading="isEnabling"
:disabled="isEnabling"
class="mr-4 flex items-center justify-center text-base"
@click="handleEnable"
>
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-2" />
{{ $t('modules.enable') }}
</BaseButton>
</div>
</div>
<!-- Highlights -->
<div v-if="moduleData.highlights" class="border-t border-line-default mt-10 pt-10">
<h3 class="text-sm font-medium text-heading">
{{ $t('modules.what_you_get') }}
</h3>
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="moduleData.highlights" />
</div>
<!-- Links -->
<div v-if="moduleData.links?.length" class="border-t border-line-default mt-10 pt-10">
<div
v-for="(link, key) in moduleData.links"
:key="key"
class="mb-4 last:mb-0 flex"
>
<BaseIcon :name="(link as ModuleLinkItem).icon ?? ''" class="mr-4" />
<a :href="(link as ModuleLinkItem).link" class="text-primary-500" target="_blank">
{{ (link as ModuleLinkItem).label }}
</a>
</div>
</div>
<!-- Installation Steps -->
<div v-if="isInstalling" class="border-t border-line-default mt-10 pt-10">
<ul class="w-full p-0 list-none">
<li
v-for="step in installationSteps"
:key="step.translationKey"
class="flex justify-between w-full py-3 border-b border-line-default border-solid last:border-b-0"
>
<p class="m-0 text-sm leading-8">{{ $t(step.translationKey) }}</p>
<span
:class="stepStatusClass(step)"
class="block py-1 text-sm text-center uppercase rounded-full"
style="width: 88px"
>
{{ stepStatusLabel(step) }}
</span>
</li>
</ul>
</div>
</div>
<!-- Tabs: Reviews, FAQ, License -->
<div class="w-full max-w-2xl mx-auto mt-16 lg:max-w-none lg:mt-0 lg:col-span-4">
<!-- Simple tab implementation -->
<div class="-mb-px flex space-x-8 border-b border-line-default">
<button
v-for="tab in tabs"
:key="tab.key"
:class="[
activeTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-body hover:text-heading hover:border-line-strong',
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Reviews -->
<div v-if="activeTab === 'reviews'" class="-mb-10">
<div v-if="moduleData.reviews?.length">
<div
v-for="(review, reviewIdx) in moduleData.reviews"
:key="reviewIdx"
class="flex text-sm text-muted space-x-4"
>
<div class="flex-none py-10">
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full bg-surface-secondary">
<span class="text-lg font-medium leading-none text-white uppercase">
{{ review.user?.[0] ?? '?' }}
</span>
</span>
</div>
<div :class="[reviewIdx === 0 ? '' : 'border-t border-line-default', 'py-10']">
<h3 class="font-medium text-heading">{{ review.user }}</h3>
<p>{{ formatDate(review.created_at) }}</p>
<div class="flex items-center mt-4">
<BaseRating :rating="review.rating" />
</div>
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="review.comment" />
</div>
</div>
</div>
<div v-else class="flex w-full items-center justify-center">
<p class="text-muted mt-10 text-sm">{{ $t('modules.no_reviews_found') }}</p>
</div>
</div>
<!-- FAQ -->
<dl v-if="activeTab === 'faq'" class="text-sm text-muted">
<template v-for="faq in moduleData.faq" :key="faq.question">
<dt class="mt-10 font-medium text-heading">{{ faq.question }}</dt>
<dd class="mt-2 prose prose-sm max-w-none text-muted">
<p>{{ faq.answer }}</p>
</dd>
</template>
</dl>
<!-- License -->
<div v-if="activeTab === 'license'" class="pt-10">
<div class="prose prose-sm max-w-none text-muted" v-html="moduleData.license" />
</div>
</div>
</div>
<!-- Other Modules -->
<div v-if="otherModules?.length" class="mt-24 sm:mt-32 lg:max-w-none">
<div class="flex items-center justify-between space-x-4">
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
<a
href="/admin/modules"
class="whitespace-nowrap text-sm font-medium text-primary-600 hover:text-primary-500"
>
{{ $t('modules.view_all') }}
<span aria-hidden="true"> &rarr;</span>
</a>
</div>
<div class="mt-6 grid grid-cols-1 gap-x-8 gap-y-8 sm:grid-cols-2 sm:gap-y-10 lg:grid-cols-4">
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
<ModuleCard :data="other" />
</div>
</div>
</div>
<div class="p-6" />
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useModuleStore } from '../store'
import type { InstallationStep } from '../store'
import ModuleCard from '../components/ModuleCard.vue'
import type { Module, ModuleLink } from '../../../../types/domain/module'
interface ModuleLinkItem {
icon: string
link: string
label: string
}
interface TabItem {
key: string
label: string
}
const moduleStore = useModuleStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const isFetchingInitialData = ref<boolean>(true)
const isInstalling = ref<boolean>(false)
const isEnabling = ref<boolean>(false)
const isDisabling = ref<boolean>(false)
const displayImage = ref<string | null>('')
const displayVideo = ref<boolean>(false)
const thumbnail = ref<string | null>(null)
const videoUrl = ref<string | null>(null)
const activeTab = ref<string>('reviews')
const installationSteps = reactive<InstallationStep[]>([])
const tabs = computed<TabItem[]>(() => [
{ key: 'reviews', label: t('modules.customer_reviews') },
{ key: 'faq', label: t('modules.faq') },
{ key: 'license', label: t('modules.license') },
])
const moduleData = computed<Module | undefined>(() => {
return moduleStore.currentModule?.data
})
const otherModules = computed<Module[] | undefined>(() => {
return moduleStore.currentModule?.meta?.modules
})
const averageRating = computed<number>(() => {
return parseInt(String(moduleData.value?.average_rating ?? 0), 10)
})
const isModuleInstalled = computed<boolean>(() => {
return !!(moduleData.value?.installed && moduleData.value?.latest_module_version)
})
const moduleVersion = computed<string>(() => {
return moduleData.value?.installed_module_version ?? moduleData.value?.latest_module_version ?? ''
})
const updatedAt = computed<string>(() => {
const date =
moduleData.value?.installed_module_version_updated_at ??
moduleData.value?.latest_module_version_updated_at
return date ? formatDate(date) : ''
})
const displayImages = computed<Array<{ url: string }>>(() => {
const images: Array<{ url: string }> = []
if (moduleData.value?.cover) {
images.push({ url: moduleData.value.cover })
}
if (moduleData.value?.screenshots) {
moduleData.value.screenshots.forEach((s) => images.push({ url: s.url }))
}
return images
})
const buyLink = computed<string>(() => {
return `/modules/${moduleData.value?.slug ?? ''}`
})
watch(() => route.params.slug, () => {
loadData()
})
onMounted(() => {
loadData()
})
async function loadData(): Promise<void> {
const slug = route.params.slug as string
if (!slug) return
isFetchingInitialData.value = true
await moduleStore.fetchModule(slug)
videoUrl.value = moduleData.value?.video_link ?? null
thumbnail.value = moduleData.value?.video_thumbnail ?? null
if (videoUrl.value) {
setDisplayVideo()
} else {
displayImage.value = moduleData.value?.cover ?? null
}
isFetchingInitialData.value = false
}
async function handleInstall(): Promise<void> {
if (!moduleData.value) return
installationSteps.length = 0
isInstalling.value = true
const success = await moduleStore.installModule(
moduleData.value.module_name,
moduleData.value.latest_module_version,
(step) => {
const existing = installationSteps.find(
(s) => s.translationKey === step.translationKey,
)
if (existing) {
Object.assign(existing, step)
} else {
installationSteps.push({ ...step })
}
},
)
isInstalling.value = false
if (success) {
setTimeout(() => location.reload(), 1500)
}
}
async function handleDisable(): Promise<void> {
if (!moduleData.value) return
const confirmed = window.confirm(t('modules.disable_warning'))
if (!confirmed) return
isDisabling.value = true
const res = await moduleStore.disableModule(moduleData.value.module_name)
isDisabling.value = false
if (res.success) {
setTimeout(() => location.reload(), 1500)
}
}
async function handleEnable(): Promise<void> {
if (!moduleData.value) return
isEnabling.value = true
const res = await moduleStore.enableModule(moduleData.value.module_name)
isEnabling.value = false
if (res.success) {
setTimeout(() => location.reload(), 1500)
}
}
function setDisplayImage(url: string): void {
displayVideo.value = false
displayImage.value = url
}
function setDisplayVideo(): void {
displayVideo.value = true
displayImage.value = null
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function stepStatusClass(step: InstallationStep): string {
const status = stepStatusLabel(step)
switch (status) {
case 'pending':
return 'text-primary-800 bg-surface-muted'
case 'finished':
return 'text-teal-500 bg-teal-100'
case 'running':
return 'text-blue-400 bg-blue-100'
default:
return 'text-danger bg-red-200'
}
}
function stepStatusLabel(step: InstallationStep): string {
if (step.started && step.completed) return 'finished'
if (step.started && !step.completed) return 'running'
return 'pending'
}
</script>