feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,7 +19,11 @@ import {
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiSegmented from '@/components/ui/UiSegmented.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
ACTIVITY_EXCEL_FIELDS,
|
||||
@@ -35,6 +39,8 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivityStageCountsDto,
|
||||
ActivityStageDto,
|
||||
ActivityStatus,
|
||||
ActivityType,
|
||||
ImportContactsResult,
|
||||
@@ -43,7 +49,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canViewAllRecords } from '@/lib/roles';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
@@ -78,14 +84,22 @@ type TypeFilter = 'all' | ActivityType;
|
||||
type StatusFilter = 'all' | ActivityStatus;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
type ViewMode = 'tabs' | 'board';
|
||||
type TimeFilter = Exclude<ActivityFilter, 'done'>;
|
||||
|
||||
const TABS: Array<{ key: ActivityFilter; label: string }> = [
|
||||
const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
|
||||
{ key: 'today', label: 'امروز' },
|
||||
{ key: 'overdue', label: 'عقبافتاده' },
|
||||
{ key: 'upcoming', label: 'بعداً' },
|
||||
{ key: 'done', label: 'انجامشده' },
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'all', label: 'همه زمانها' },
|
||||
];
|
||||
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key);
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: 'tabs' as const, label: 'نمای تب', icon: 'list' as const },
|
||||
{ value: 'board' as const, label: 'نمای ستونی', icon: 'grid' as const },
|
||||
];
|
||||
const VIEW_STORAGE_KEY = 'activities-view-mode';
|
||||
|
||||
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
||||
{ key: 'mine', label: 'میز من' },
|
||||
@@ -113,13 +127,41 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
|
||||
const canManageStages = computed(() => canManageSettings(auth.user?.role));
|
||||
|
||||
const filter = computed<ActivityFilter>(() => {
|
||||
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
|
||||
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
|
||||
return value as ActivityFilter;
|
||||
function storedViewMode(): ViewMode {
|
||||
try {
|
||||
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
|
||||
} catch {
|
||||
return 'tabs';
|
||||
}
|
||||
return 'today';
|
||||
}
|
||||
|
||||
const viewMode = computed<ViewMode>(() => {
|
||||
if (route.query.view === 'board') return 'board';
|
||||
if (route.query.view === 'tabs') return 'tabs';
|
||||
return storedViewMode();
|
||||
});
|
||||
|
||||
/** پیشفرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمانها» */
|
||||
const defaultTime = computed<TimeFilter>(() => (viewMode.value === 'board' ? 'all' : 'today'));
|
||||
|
||||
const filter = computed<TimeFilter>(() => {
|
||||
const value = typeof route.query.filter === 'string' ? route.query.filter : '';
|
||||
if (TIME_KEYS.includes(value as TimeFilter)) return value as TimeFilter;
|
||||
if (value === 'done') return 'all';
|
||||
return defaultTime.value;
|
||||
});
|
||||
|
||||
const stages = ref<ActivityStageDto[]>([]);
|
||||
const stagesManagerOpen = ref(false);
|
||||
const kanbanReload = ref(0);
|
||||
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
|
||||
|
||||
const stage = computed<string>(() => {
|
||||
const value = typeof route.query.stage === 'string' ? route.query.stage : 'all';
|
||||
if (value === 'all' || stages.value.some((s) => s.id === value)) return value;
|
||||
return 'all';
|
||||
});
|
||||
|
||||
const desk = computed<DeskScope>(() => {
|
||||
@@ -161,17 +203,17 @@ const assigneeFilter = computed(() =>
|
||||
const activityModal = ref<{
|
||||
type?: ActivityType;
|
||||
title?: string;
|
||||
stageId?: string;
|
||||
} | null>(null);
|
||||
const menuId = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const pageBusy = ref(false);
|
||||
const busyMessage = ref('لطفاً صبر کنید…');
|
||||
const importMsg = ref('');
|
||||
const counts = ref<Record<ActivityFilter, number>>({
|
||||
const counts = ref<Record<TimeFilter, number>>({
|
||||
today: 0,
|
||||
overdue: 0,
|
||||
upcoming: 0,
|
||||
done: 0,
|
||||
all: 0,
|
||||
});
|
||||
|
||||
@@ -209,7 +251,26 @@ const items = computed(() => data.value?.items ?? []);
|
||||
|
||||
const total = computed(() => data.value?.total ?? 0);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
|
||||
const stageById = computed(() => new Map(stages.value.map((s) => [s.id, s])));
|
||||
const filterLabel = computed(() => {
|
||||
const time = TIME_TABS.find((t) => t.key === filter.value)?.label ?? 'امروز';
|
||||
const st = stage.value === 'all' ? 'همه ستونها' : stageById.value.get(stage.value)?.name ?? '';
|
||||
return `${st} · ${time}`;
|
||||
});
|
||||
|
||||
/** فیلترهای مشترک لیست، شمارشها و کانبان */
|
||||
const sharedParams = computed<Record<string, string | undefined>>(() => ({
|
||||
filter: filter.value,
|
||||
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
}));
|
||||
|
||||
function stageDotStyle(s: ActivityStageDto) {
|
||||
return { background: PIPELINE_ACCENT[s.color].solid };
|
||||
}
|
||||
const activeFacetCount = computed(() => {
|
||||
let n = 0;
|
||||
if (typeFilter.value !== 'all') n += 1;
|
||||
@@ -220,7 +281,14 @@ const activeFacetCount = computed(() => {
|
||||
const filterBtnLabel = computed(() =>
|
||||
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
|
||||
);
|
||||
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
|
||||
const trulyEmpty = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
counts.value.all === 0 &&
|
||||
stage.value === 'all' &&
|
||||
!q.value.trim() &&
|
||||
!activeFacetCount.value,
|
||||
);
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
|
||||
function isOverdue(item: ActivityDto) {
|
||||
@@ -269,21 +337,43 @@ function subtitle(item: ActivityDto) {
|
||||
return item.deal?.title || item.description || '';
|
||||
}
|
||||
|
||||
async function loadStages() {
|
||||
try {
|
||||
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages');
|
||||
stages.value = rows;
|
||||
} catch {
|
||||
error.value = 'بارگذاری ستونهای فعالیت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
/** شمارش تبهای زمانی (با ستون انتخابی در نمای تب) و شمارش ستونها (با زمان انتخابی) */
|
||||
async function loadCounts() {
|
||||
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
|
||||
const deskParam = showDeskTabs.value ? desk.value : undefined;
|
||||
const results = await Promise.all(
|
||||
keys.map((key) =>
|
||||
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: { filter: key, limit: 1, desk: deskParam },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const next = { ...counts.value };
|
||||
keys.forEach((key, i) => {
|
||||
next[key] = results[i]?.data.total ?? 0;
|
||||
});
|
||||
counts.value = next;
|
||||
const base = sharedParams.value;
|
||||
const stageParam = viewMode.value === 'tabs' ? stage.value : 'all';
|
||||
try {
|
||||
const [timeResults, stageRes] = await Promise.all([
|
||||
Promise.all(
|
||||
TIME_KEYS.map((key) =>
|
||||
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: { ...base, filter: key, stageId: stageParam, limit: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
api.get<ActivityStageCountsDto>('/activities/stage-counts', { params: base }),
|
||||
]);
|
||||
const next = { ...counts.value };
|
||||
TIME_KEYS.forEach((key, i) => {
|
||||
next[key] = timeResults[i]?.data.total ?? 0;
|
||||
});
|
||||
counts.value = next;
|
||||
stageCounts.value = stageRes.data;
|
||||
} catch {
|
||||
/* شمارشها اختیاریاند؛ خطای اصلی را load نشان میدهد */
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([viewMode.value === 'tabs' ? load() : Promise.resolve(), loadCounts()]);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -300,20 +390,17 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (viewMode.value !== 'tabs') return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
menuId.value = null;
|
||||
try {
|
||||
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: {
|
||||
filter: filter.value,
|
||||
q: q.value || undefined,
|
||||
...sharedParams.value,
|
||||
stageId: stage.value,
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
@@ -339,18 +426,65 @@ function setDesk(next: DeskScope) {
|
||||
});
|
||||
}
|
||||
|
||||
function setFilter(next: ActivityFilter) {
|
||||
function setFilter(next: TimeFilter) {
|
||||
filterOpen.value = false;
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
filter: next === 'today' ? undefined : next,
|
||||
filter: next === defaultTime.value ? undefined : next,
|
||||
page: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setStage(next: string) {
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
stage: next === 'all' ? undefined : next,
|
||||
page: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setViewMode(next: ViewMode) {
|
||||
try {
|
||||
localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
view: next,
|
||||
filter: undefined,
|
||||
page: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const viewModel = computed<ViewMode>({
|
||||
get: () => viewMode.value,
|
||||
set: (v) => setViewMode(v),
|
||||
});
|
||||
|
||||
function onStagesChanged() {
|
||||
void loadStages().then(() => loadCounts());
|
||||
}
|
||||
|
||||
function openAddInStage(stageId: string) {
|
||||
activityModal.value = { stageId };
|
||||
}
|
||||
|
||||
function onActivitySaved() {
|
||||
activityModal.value = null;
|
||||
kanbanReload.value += 1;
|
||||
void refreshAll();
|
||||
}
|
||||
|
||||
function setPage(next: number) {
|
||||
const safe = Math.min(Math.max(1, next), pageCount.value);
|
||||
void router.replace({
|
||||
@@ -394,17 +528,7 @@ function setTypeFilter(next: TypeFilter) {
|
||||
}
|
||||
|
||||
function setStatusFilter(next: StatusFilter) {
|
||||
// Avoid empty results when a time-tab forces a different status.
|
||||
const nextFilter =
|
||||
next === 'all'
|
||||
? route.query.filter
|
||||
: next === 'done'
|
||||
? 'done'
|
||||
: 'all';
|
||||
patchFacetQuery({
|
||||
status: next === 'all' ? undefined : next,
|
||||
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
|
||||
});
|
||||
patchFacetQuery({ status: next === 'all' ? undefined : next });
|
||||
}
|
||||
|
||||
function setAssigneeFilter(next: string) {
|
||||
@@ -426,7 +550,7 @@ async function complete(id: string) {
|
||||
menuId.value = null;
|
||||
try {
|
||||
await api.patch(`/activities/${id}/complete`);
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
await refreshAll();
|
||||
} catch {
|
||||
error.value = 'ثبت انجامشدن ناموفق بود.';
|
||||
}
|
||||
@@ -476,7 +600,8 @@ async function onImportDone(data: ImportContactsResult) {
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
@@ -497,6 +622,8 @@ watch(
|
||||
route.query.type,
|
||||
route.query.status,
|
||||
route.query.assigneeId,
|
||||
route.query.stage,
|
||||
route.query.view,
|
||||
],
|
||||
() => {
|
||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||
@@ -505,6 +632,21 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
/** لینکهای قدیمی ?filter=done → ستون انجامشده */
|
||||
function migrateLegacyDoneFilter() {
|
||||
if (route.query.filter !== 'done') return false;
|
||||
const doneStage = stages.value.find((s) => s.kind === 'done');
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
filter: 'all',
|
||||
stage: doneStage?.id,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
function syncMobile() {
|
||||
isMobile.value = mobileQuery?.matches ?? false;
|
||||
@@ -515,8 +657,11 @@ onMounted(() => {
|
||||
syncMobile();
|
||||
mobileQuery.addEventListener('change', syncMobile);
|
||||
void loadUsers();
|
||||
void load();
|
||||
void loadCounts();
|
||||
void loadStages().then(() => {
|
||||
if (migrateLegacyDoneFilter()) return;
|
||||
void load();
|
||||
void loadCounts();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -575,32 +720,77 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
|
||||
<div class="view-row">
|
||||
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
|
||||
<button
|
||||
v-for="tab in DESK_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: desk === tab.key }"
|
||||
:aria-selected="desk === tab.key"
|
||||
@click="setDesk(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-row-end">
|
||||
<UiButton
|
||||
v-if="canManageStages"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
@click="stagesManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="settings" :size="15" />
|
||||
مدیریت ستونها
|
||||
</UiButton>
|
||||
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
|
||||
<template #icon="{ option }">
|
||||
<UiIcon :name="option.icon ?? 'list'" :size="15" />
|
||||
</template>
|
||||
</UiSegmented>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستونهای فعالیت">
|
||||
<button
|
||||
v-for="tab in DESK_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: desk === tab.key }"
|
||||
:aria-selected="desk === tab.key"
|
||||
@click="setDesk(tab.key)"
|
||||
:class="{ on: stage === 'all' }"
|
||||
:aria-selected="stage === 'all'"
|
||||
@click="setStage('all')"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span>همه</span>
|
||||
<em>{{ toFaDigits(stageCounts.total.toLocaleString('en-US')) }}</em>
|
||||
</button>
|
||||
<button
|
||||
v-for="s in stages"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: stage === s.id }"
|
||||
:aria-selected="stage === s.id"
|
||||
@click="setStage(s.id)"
|
||||
>
|
||||
<i class="stage-dot" :style="stageDotStyle(s)" aria-hidden="true" />
|
||||
<span>{{ s.name }}</span>
|
||||
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
|
||||
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
v-for="tab in TIME_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: filter === tab.key }"
|
||||
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
|
||||
:aria-selected="filter === tab.key"
|
||||
@click="setFilter(tab.key)"
|
||||
>
|
||||
<span>{{ tab.label }}</span>
|
||||
<em>{{ toFaDigits(counts[tab.key]) }}</em>
|
||||
{{ tab.label }}
|
||||
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -681,7 +871,20 @@ onUnmounted(() => {
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<ActivitiesKanban
|
||||
v-if="viewMode === 'board'"
|
||||
:stages="stages"
|
||||
:params="sharedParams"
|
||||
:can-manage="canManageStages"
|
||||
:reload-key="kanbanReload"
|
||||
@changed="loadCounts"
|
||||
@stages-changed="onStagesChanged"
|
||||
@add="openAddInStage"
|
||||
@error="(msg: string) => (error = msg)"
|
||||
/>
|
||||
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="listEmpty" class="empty-card">
|
||||
<div class="art" aria-hidden="true">
|
||||
@@ -780,11 +983,15 @@ onUnmounted(() => {
|
||||
{{ item.company?.name || item.person?.fullName }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="colOn('status')">
|
||||
<div v-if="colOn('status')" class="status-cell">
|
||||
<span class="status" :class="uiStatus(item)">
|
||||
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
|
||||
{{ statusLabel[uiStatus(item)] }}
|
||||
</span>
|
||||
<small v-if="item.stageId && stageById.get(item.stageId)" class="stage-name">
|
||||
<i class="stage-dot" :style="stageDotStyle(stageById.get(item.stageId)!)" aria-hidden="true" />
|
||||
{{ stageById.get(item.stageId)!.name }}
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="!isMobile" class="row-more" @click.stop>
|
||||
<UiDropdownMenu
|
||||
@@ -901,10 +1108,19 @@ onUnmounted(() => {
|
||||
embedded
|
||||
:initial-type="activityModal.type"
|
||||
:initial-title="activityModal.title"
|
||||
@saved="activityModal = null; void load(); void loadCounts()"
|
||||
:initial-stage-id="activityModal.stageId"
|
||||
@saved="onActivitySaved"
|
||||
@cancel="activityModal = null"
|
||||
/>
|
||||
</UiModal>
|
||||
|
||||
<ActivityStagesManager
|
||||
v-if="canManageStages"
|
||||
:open="stagesManagerOpen"
|
||||
:stages="stages"
|
||||
@close="stagesManagerOpen = false"
|
||||
@changed="onStagesChanged(); kanbanReload += 1"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1049,6 +1265,85 @@ h1 {
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.view-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-row-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.stage-dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.time-chips {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.time-chips button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 0.32rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-chips button em {
|
||||
font-style: normal;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.time-chips button.late em {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.time-chips button.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
|
||||
@@ -46,6 +46,7 @@ const props = withDefaults(
|
||||
initialPersonId?: string;
|
||||
initialType?: ActivityType;
|
||||
initialTitle?: string;
|
||||
initialStageId?: string;
|
||||
}>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
@@ -438,7 +439,10 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
||||
try {
|
||||
let savedId = id.value;
|
||||
if (isNew.value) {
|
||||
const { data } = await api.post<ActivityDto>('/activities', payload());
|
||||
const { data } = await api.post<ActivityDto>('/activities', {
|
||||
...payload(),
|
||||
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
|
||||
});
|
||||
savedId = data.id;
|
||||
if (isDone.value) {
|
||||
await api.patch(`/activities/${savedId}/complete`);
|
||||
|
||||
Reference in New Issue
Block a user