Files
portal/app/resources/js/components/projects/ProjectCard.vue
T
Дмитрий 55a9d3fe00 fix(types): unify Project interface + NavItem.countKey + drop legacy Record
vue-tsc was emitting 9 errors from two issues:

1. ProjectCard.vue had a local `interface Project` missing region_mask /
   region_mode / delivery_days_mask, while stores/projectsStore.ts
   exported the canonical one with those fields. ProjectsView.vue passed
   the canonical Project to ProjectCard handler signatures which expected
   the local incomplete one → 5× TS2322.

2. EditProjectDialog passed `project: Project | Record<string, unknown>`
   to NewProjectDialog which expected `Record<string, unknown> | null`.
   Project lacks an index signature → TS2322.

3. AppSidebar.vue template referenced `item.countKey` not declared in
   NavItem interface → 2× TS2339.

Changes:
- ProjectCard.vue: drop local Project, import from projectsStore.
- NewProjectDialog.vue: project prop type → Project | null (was Record).
  Drop `as { id: number }` cast on PATCH URL.
- EditProjectDialog.vue: project prop type → Project | null.
- AppSidebar.vue: add `countKey?: string` to NavItem.
- projectsStore.ts: make region_mask/region_mode/delivery_days_mask
  optional (backward-compat for mock fixtures; production rows always
  populate them by schema).
- Test/story fixtures expanded with delivered_today/is_active/archived_at/
  sync_status to match strict Project shape.

Verification:
- npx vue-tsc --noEmit → 0 errors (was 9).
- npx vitest run on 5 affected specs → 16/16 passed + 2 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:15:26 +03:00

169 lines
6.2 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"
@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" 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('archive', project)">
<template #prepend><v-icon>mdi-archive</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 />
</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];
archive: [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.10);
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>