Files
KanbanGit/resources/js/pages/project-templates/Index.vue
T
Twyco 9a7ccb1160
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 19m8s
CI/CD / Publish immutable image (pull_request) Skipped
feat(KAN-46): rebuild reusable project template management
2026-09-15 22:42:53 +02:00

259 lines
10 KiB
Vue

<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates';
import {
create as createProject,
index as projectsIndex,
} from '@/routes/projects';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<ProjectTemplatesIndexPageProps>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.templates'), href: index() },
]);
const templates = computed(() =>
props.templates.filter((template) => template.is_published),
);
const pending = ref<ProjectTemplateModel | null>(null);
const form = useForm({});
const protection = useFormProtection(form);
function requestDelete(template: ProjectTemplateModel): void {
form.resetAndClearErrors();
protection.clearFeedback();
pending.value = template;
}
function close(open: boolean): void {
if (!open && !form.processing) {
pending.value = null;
form.clearErrors();
protection.clearFeedback();
}
}
function removeTemplate(): void {
if (!pending.value) {
return;
}
protection.submit(destroy(pending.value.id), {
preserveScroll: true,
onSuccess: () => {
pending.value = null;
},
});
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('navigation.templates')" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
<header
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.templates') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('templates.description') }}
</p>
</div>
<Button as-child class="shrink-0"
><Link :href="createProject()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
></Button
>
</header>
<FeedbackState
v-if="status"
state="success"
:title="t('templates.updated')"
/>
<section
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
aria-labelledby="save-template-help"
>
<LayoutTemplate
class="text-primary size-6 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0 flex-1">
<h2 id="save-template-help" class="text-title">
{{ t('templates.fromProject') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('templates.fromProjectHelp') }}
</p>
</div>
<Button as-child variant="outline" class="shrink-0"
><Link :href="projectsIndex()"
>{{ t('templates.browseProjects')
}}<ArrowRight aria-hidden="true" /></Link
></Button>
</section>
<FeedbackState
v-if="templates.length === 0"
state="empty"
:title="t('templates.empty')"
:description="t('templates.emptyHelp')"
/>
<ul
v-else
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
:aria-label="t('templates.published')"
>
<li
v-for="template in templates"
:key="template.id"
class="bg-card flex min-w-0 flex-col rounded-md border"
:data-template="template.id"
>
<div class="gap-column grid min-w-0 p-5">
<div class="gap-stack grid min-w-0">
<h2 class="text-title break-words">
{{ template.name }}
</h2>
<p
class="text-caption text-muted-foreground break-all"
>
{{ template.slug }}
</p>
</div>
<Badge
variant="secondary"
class="w-fit whitespace-normal"
>{{
t('templates.linkedCount', {
count: template.linked_project_count ?? 0,
})
}}</Badge
>
<p
class="text-caption text-muted-foreground break-words"
>
{{
template.source_project
? t('templates.savedFrom', {
project: template.source_project.name,
key: template.source_project.key,
})
: t('templates.sourceUnavailable')
}}
</p>
<ul
class="flex flex-wrap gap-2"
:aria-label="t('templates.groups')"
>
<li v-for="area in template.copy_areas" :key="area">
<Badge
variant="outline"
class="whitespace-normal"
>{{ areaText(area, 'label') }}</Badge
>
</li>
</ul>
</div>
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
<Button as-child variant="secondary"
><Link
:href="
createProject({
query: { template: template.id },
})
"
>{{ t('templates.use') }}</Link
></Button
>
<Button as-child variant="outline"
><Link
:href="edit(template.id)"
:aria-label="
t('templates.editTitle', {
name: template.name,
})
"
>{{ t('templates.edit') }}</Link
></Button
>
<Button
variant="ghost"
size="icon"
class="text-destructive ml-auto"
:aria-label="
t('templates.deleteTitle', {
name: template.name,
})
"
@click="requestDelete(template)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</li>
</ul>
</div>
<Dialog :open="pending !== null" @update:open="close">
<DialogContent :show-close-button="!form.processing">
<DialogHeader>
<DialogTitle>{{
t('templates.deleteTitle', {
name: pending?.name ?? '',
})
}}</DialogTitle>
<DialogDescription>{{
t('templates.deleteHelp')
}}</DialogDescription>
</DialogHeader>
<p v-if="pending?.linked_project_count" class="text-body">
{{
t('templates.deleteImpact', {
count: pending.linked_project_count,
})
}}
</p>
<FormFeedback
:state="protection.state.value"
@retry="removeTemplate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="form.processing"
:loading-label="t('templates.deleting')"
@click="removeTemplate"
>{{ t('templates.delete') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout>
</template>