Files
KanbanGit/resources/js/pages/projects/settings/General.vue
T
Twyco bfec4f464e
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
feat(KAN-39): rebuild general project settings and inheritance
2026-09-15 16:02:41 +02:00

488 lines
22 KiB
Vue

<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Layers3, Trash2 } from 'lucide-vue-next';
import { computed } from 'vue';
import {
destroy,
switchTemplate,
} from '@/actions/App/Http/Controllers/ProjectController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects/index';
import { general } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
const props = defineProps<ProjectGeneralSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.general'), href: general(props.project.id) },
]);
const switchTemplateForm = useForm<{
confirmation_key: string;
source_template_id: number | undefined;
}>({
confirmation_key: '',
source_template_id: undefined,
});
const deleteForm = useForm({ confirmation_key: '' });
const switchProtection = useFormProtection(switchTemplateForm);
const deleteProtection = useFormProtection(deleteForm);
const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const canSwitchTemplate = computed(
() =>
switchTemplateForm.confirmation_key === props.project.key &&
availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
);
const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key,
);
const inheritanceAreas = computed(() =>
[
...(props.templateState?.inheritedAreas ?? []),
...(props.templateState?.overriddenAreas ?? []),
].map((area) => area.value),
);
const settingsSource = computed(() => {
if (!props.templateState?.inheritsFromTemplate) {
return 'projectSettings.local';
}
if (
props.templateState.inheritedAreas.some(
(area) => area.value === 'project_settings',
)
) {
return 'projectSettings.inherited';
}
return props.templateState.overriddenAreas.some(
(area) => area.value === 'project_settings',
)
? 'projectSettings.override'
: 'projectSettings.local';
});
function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value || processing.value) {
return;
}
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
function submitDelete(): void {
if (!isDeleteConfirmationValid.value || processing.value) {
return;
}
deleteProtection.submit(destroy(props.project.id), {
preserveScroll: true,
});
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head
:title="t('projectSettings.pageTitle', { project: project.name })"
/>
<SettingsLayout :project-name="project.name" :project-key="project.key">
<FeedbackState
v-if="status"
state="success"
:title="t('projectSettings.updated')"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
{{ t('projectSettings.generalDescription') }}
</p>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('projectSettings.identity') }}
</h3>
<Badge variant="secondary">{{
t('projectSettings.local')
}}</Badge>
</div>
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.name') }}
</dt>
<dd class="break-words font-semibold">
{{ project.name }}
</dd>
</div>
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.key') }}
</dt>
<dd class="break-all font-semibold">
{{ project.key }}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.identityDescription') }}
</p>
<div
class="flex flex-wrap items-center gap-2 border-t pt-4"
>
<h3 class="font-semibold">
{{ t('projectSettings.display') }}
</h3>
<Badge
:variant="
settingsSource === 'projectSettings.inherited'
? 'outline'
: 'secondary'
"
>{{ t(settingsSource) }}</Badge
>
</div>
<dl class="grid gap-3">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showAuthor') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_author
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showStoryPoints') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_story_points
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.displayDescription') }}
</p>
</CardContent>
</Card>
<TemplateInheritancePanel
:project-id="project.id"
:areas="inheritanceAreas"
:template-state="templateState"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.template') }}
</h2>
</div>
<p class="text-muted-foreground">
{{ t('projectSettings.templateDescription') }}
</p>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.currentTemplate') }}
</p>
<p class="break-words font-semibold">
{{
currentTemplate?.name ??
t('projectSettings.noTemplate')
}}
</p>
<Badge variant="outline" class="justify-self-start">{{
t(
templateState?.inheritsFromTemplate
? 'projectSettings.linked'
: 'projectSettings.standalone',
)
}}</Badge>
<p
v-if="!templateState?.inheritsFromTemplate"
class="text-caption text-muted-foreground"
>
{{ t('projectSettings.standaloneDescription') }}
</p>
</div>
<FeedbackState
v-if="availableSwitchTemplates.length === 0"
state="empty"
:title="t('projectSettings.noAlternatives')"
:description="
t('projectSettings.noAlternativesDescription')
"
/>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="switchTemplateForm.processing"
@submit.prevent="submitSwitchTemplate"
>
<FormFeedback
:state="switchProtection.state.value"
:title="
switchTemplateForm.processing
? t('projectSettings.switching')
: undefined
"
@retry="submitSwitchTemplate"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="mb-3 font-semibold">
{{ t('projectSettings.switch') }}
</legend>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
>
{{ t('projectSettings.switchWarning') }}
</p>
<div class="grid gap-2">
<Label for="source_template_id">{{
t('projectSettings.publishedTemplate')
}}</Label>
<select
id="source_template_id"
v-model="
switchTemplateForm.source_template_id
"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
:aria-invalid="
!!switchTemplateForm.errors
.source_template_id
"
aria-describedby="source-template-error"
>
<option :value="undefined" disabled>
{{
t('projectSettings.selectTemplate')
}}
</option>
<option
v-for="template in availableSwitchTemplates"
:key="template.id"
:value="template.id"
>
{{ template.name }}
</option>
</select>
<InputError
id="source-template-error"
:message="
switchTemplateForm.errors
.source_template_id
"
/>
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="switch_confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!switchTemplateForm.errors
.confirmation_key
"
aria-describedby="switch-confirm-help switch-confirm-error"
/>
<p
id="switch-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="switch-confirm-error"
:message="
switchTemplateForm.errors
.confirmation_key
"
/>
</div>
<Button
type="submit"
variant="outline"
class="justify-self-start"
:disabled="!canSwitchTemplate"
>{{
t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
>
</fieldset>
</form>
</CardContent>
</Card>
<Card
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
>
<div class="flex items-center gap-2">
<Trash2
class="text-destructive size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.danger') }}
</h2>
</div>
<p class="text-muted-foreground">
{{ t('projectSettings.deleteDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="deleteForm.processing"
@submit.prevent="submitDelete"
>
<FormFeedback
:state="deleteProtection.state.value"
:title="
deleteForm.processing
? t('projectSettings.deleting')
: undefined
"
@retry="submitDelete"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="sr-only">
{{ t('projectSettings.delete') }}
</legend>
<p class="text-caption font-medium">
{{ t('projectSettings.irreversible') }}
</p>
<div class="grid gap-2">
<Label for="confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!deleteForm.errors.confirmation_key
"
aria-describedby="delete-confirm-help delete-confirm-error"
/>
<p
id="delete-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="delete-confirm-error"
:message="
deleteForm.errors.confirmation_key
"
/>
</div>
<Button
type="submit"
variant="destructive"
class="justify-self-start"
:disabled="!isDeleteConfirmationValid"
>{{
t(
deleteForm.processing
? 'projectSettings.deleting'
: 'projectSettings.delete',
)
}}</Button
>
</fieldset>
</form>
</CardContent>
</Card>
</SettingsLayout>
</AppLayout>
</template>