3bbd7787d8
- Remove archived_at from Project interface; rename store.archive → store.del
- BulkActionsBar: archive button → delete (testid, icon, confirm text)
- ProjectCard: archive menu item → delete (emit + icon)
- ProjectDetailsDrawer: confirm text + store.del call
- ProjectsView: @delete binding, remove 'Архивные' status filter entry
- vuetify.ts: add mdi-delete → Trash2 mapping
- All specs/stories updated: archived_at removed, archive → del renamed
- New test: del() calls DELETE /api/projects/{id}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
6.9 KiB
Vue
181 lines
6.9 KiB
Vue
<template>
|
|
<v-card class="project-card ld-hover-lift" :class="{ paused: !project.is_active }" elevation="1">
|
|
<v-card-item>
|
|
<template #prepend>
|
|
<label class="card-check" data-testid="card-select">
|
|
<input
|
|
type="checkbox"
|
|
:checked="selected"
|
|
:aria-label="`Выбрать проект «${project.name}»`"
|
|
@change="$emit('toggle-select', project.id)"
|
|
/>
|
|
<span class="card-check__box" />
|
|
</label>
|
|
</template>
|
|
|
|
<v-card-title>
|
|
{{ project.name }}
|
|
<v-chip size="x-small" :color="typeColor" class="ml-2">{{ typeLabel }}</v-chip>
|
|
</v-card-title>
|
|
|
|
<v-card-subtitle>{{ identifierDisplay }}</v-card-subtitle>
|
|
|
|
<template #append>
|
|
<v-menu>
|
|
<template #activator="{ props: menuProps }">
|
|
<v-btn
|
|
icon="mdi-dots-vertical"
|
|
variant="text"
|
|
size="small"
|
|
:aria-label="`Меню действий проекта «${project.name}»`"
|
|
v-bind="menuProps"
|
|
/>
|
|
</template>
|
|
<v-list density="compact">
|
|
<v-list-item @click="$emit('edit', project)">
|
|
<template #prepend><v-icon>mdi-pencil</v-icon></template>
|
|
<v-list-item-title>Редактировать</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="$emit('toggle-active', project)">
|
|
<template #prepend
|
|
><v-icon>{{ project.is_active ? 'mdi-pause' : 'mdi-play' }}</v-icon></template
|
|
>
|
|
<v-list-item-title>{{
|
|
project.is_active ? 'Приостановить' : 'Возобновить'
|
|
}}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="$emit('sync-now', project)">
|
|
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
|
<v-list-item-title>Синхронизировать</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="$emit('delete', project)">
|
|
<template #prepend><v-icon>mdi-delete</v-icon></template>
|
|
<v-list-item-title>Удалить</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</template>
|
|
</v-card-item>
|
|
|
|
<v-card-text>
|
|
<div v-if="project.is_active" class="mb-2">
|
|
<div class="d-flex justify-space-between">
|
|
<span class="text-caption"
|
|
><span class="ld-mono">{{ project.delivered_today }}</span> /
|
|
<span class="ld-mono">{{ project.daily_limit_target }}</span> лидов</span
|
|
>
|
|
<span class="text-caption text-medium-emphasis"
|
|
><span class="ld-mono">{{ progressPercent }}</span
|
|
>%</span
|
|
>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="progressPercent"
|
|
:color="progressColor"
|
|
height="6"
|
|
rounded
|
|
:aria-label="`Прогресс дневной нормы: ${progressPercent}%`"
|
|
/>
|
|
</div>
|
|
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
|
|
|
|
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
|
|
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
|
{{ syncStatusLabel }}
|
|
</v-chip>
|
|
</v-card-text>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import type { Project } from '../../stores/projectsStore';
|
|
|
|
const props = defineProps<{ project: Project; selected: boolean }>();
|
|
defineEmits<{
|
|
'toggle-select': [id: number];
|
|
edit: [project: Project];
|
|
'toggle-active': [project: Project];
|
|
'sync-now': [project: Project];
|
|
delete: [project: Project];
|
|
}>();
|
|
|
|
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
|
|
const typeColor = computed(
|
|
() => ({ site: 'blue-lighten-4', call: 'orange-lighten-4', sms: 'purple-lighten-4' })[props.project.signal_type],
|
|
);
|
|
const identifierDisplay = computed(() => {
|
|
if (props.project.signal_type === 'sms') {
|
|
return [(props.project.sms_senders ?? []).join(', '), props.project.sms_keyword].filter(Boolean).join(' · ');
|
|
}
|
|
return props.project.signal_identifier ?? '';
|
|
});
|
|
const progressPercent = computed(() =>
|
|
Math.min(100, Math.round((props.project.delivered_today / props.project.daily_limit_target) * 100)),
|
|
);
|
|
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
|
|
const syncStatusLabel = computed(
|
|
() => ({ ok: 'Sync OK', pending: 'Sync pending', failed: 'Sync failed' })[props.project.sync_status],
|
|
);
|
|
const syncStatusIcon = computed(
|
|
() =>
|
|
({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
|
|
props.project.sync_status
|
|
],
|
|
);
|
|
const syncStatusColor = computed(
|
|
() => ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
|
|
);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.project-card.paused {
|
|
opacity: 0.75;
|
|
}
|
|
.card-check {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
.card-check input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.card-check__box {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 1px solid var(--liderra-line);
|
|
border-radius: var(--radius-6);
|
|
background: var(--liderra-surface);
|
|
display: inline-block;
|
|
position: relative;
|
|
transition:
|
|
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
|
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
.card-check:hover .card-check__box {
|
|
border-color: var(--liderra-line-strong);
|
|
}
|
|
.card-check input:focus-visible + .card-check__box {
|
|
outline: 2px solid var(--liderra-teal);
|
|
outline-offset: 2px;
|
|
}
|
|
.card-check input:checked + .card-check__box {
|
|
background: rgba(15, 110, 86, 0.1);
|
|
border-color: var(--liderra-teal);
|
|
}
|
|
.card-check input:checked + .card-check__box::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 4px;
|
|
top: 0;
|
|
width: 5px;
|
|
height: 9px;
|
|
border: solid var(--liderra-teal);
|
|
border-width: 0 1.5px 1.5px 0;
|
|
transform: rotate(45deg);
|
|
}
|
|
</style>
|