Files
crm/apps/web/src/views/ActivityFormView.vue
T

1189 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import {
formatJalaliNumeric,
formatLocalDateInput,
formatLocalDateTimeInput,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import type {
ActivityDto,
ActivityPriority,
ActivityType,
CreateActivityRequest,
DealDto,
OrgUserDto,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'activity-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const tab = ref<'main' | 'notes'>('main');
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const title = ref('');
const type = ref<ActivityType | ''>('');
const priority = ref<ActivityPriority>('medium');
const description = ref('');
const dueDate = ref('');
const dueTime = ref('10:30');
const durationMinutes = ref(30);
const personId = ref('');
const dealId = ref('');
const assigneeId = ref('');
const isDone = ref(false);
const needsNote = ref(false);
const reminderAt = ref('');
const reminderNotifyApp = ref(true);
const reminderNotifySms = ref(false);
const typeOpen = ref(false);
const assigneeOpen = ref(false);
const personOpen = ref(false);
const typeOptions: Array<{
value: ActivityType;
label: string;
icon: 'phone' | 'users' | 'check' | 'chat';
}> = [
{ value: 'call', label: 'تماس', icon: 'phone' },
{ value: 'meeting', label: 'جلسه', icon: 'users' },
{ value: 'task', label: 'وظیفه', icon: 'check' },
{ value: 'note', label: 'یادداشت', icon: 'chat' },
];
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
{ value: 'high', label: 'زیاد', tone: 'high' },
{ value: 'medium', label: 'متوسط', tone: 'medium' },
{ value: 'low', label: 'کم', tone: 'low' },
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
];
const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedAssignee = computed(() => users.value.find((u) => u.id === assigneeId.value) ?? null);
const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
const personDeals = computed(() =>
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
);
const personSub = computed(
() => selectedDeal.value?.title || selectedPerson.value?.company?.name || '',
);
const duePreview = computed(() => {
if (!dueDate.value) return '—';
const d = parseLocalInput(`${dueDate.value}T${dueTime.value || '00:00'}`);
if (!d) return '—';
return `${formatJalaliNumeric(d)} ${toFaDigits((dueTime.value || '00:00').replace(':', ':'))}`;
});
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
const priorityText = computed(
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—',
);
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
function pad(n: number) {
return String(n).padStart(2, '0');
}
function splitDue(iso: string | null) {
if (!iso) return;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return;
dueDate.value = formatLocalDateInput(d);
dueTime.value = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function defaultDue() {
const d = new Date();
d.setHours(10, 30, 0, 0);
dueDate.value = formatLocalDateInput(d);
dueTime.value = '10:30';
}
function resetForm() {
title.value = '';
type.value = '';
priority.value = 'medium';
description.value = '';
defaultDue();
durationMinutes.value = 30;
personId.value = '';
dealId.value = '';
assigneeId.value = auth.user?.id ?? '';
isDone.value = false;
needsNote.value = false;
reminderAt.value = '';
reminderNotifyApp.value = true;
reminderNotifySms.value = false;
tab.value = 'main';
error.value = '';
}
watch(personId, () => {
if (dealId.value && !personDeals.value.some((d) => d.id === dealId.value)) {
dealId.value = personDeals.value[0]?.id ?? '';
} else if (!dealId.value && personDeals.value.length === 1) {
dealId.value = personDeals.value[0].id;
}
});
watch(needsNote, (on) => {
if (on) tab.value = 'notes';
});
async function load() {
loading.value = true;
error.value = '';
try {
const [peopleRes, boardRes, usersRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
users.value = usersRes.data;
if (!isNew.value && id.value) {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title;
type.value = data.type;
priority.value = data.priority ?? 'medium';
description.value = data.description ?? '';
needsNote.value = Boolean(data.description?.trim());
splitDue(data.dueAt);
durationMinutes.value = data.durationMinutes ?? 30;
reminderAt.value = data.reminderAt
? formatLocalDateTimeInput(new Date(data.reminderAt))
: '';
reminderNotifyApp.value = data.reminderNotifyApp ?? true;
reminderNotifySms.value = data.reminderNotifySms ?? false;
personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? '';
assigneeId.value = data.assignee?.id ?? auth.user?.id ?? '';
isDone.value = data.status === 'done';
} else {
resetForm();
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
}
if (
typeof route.query.type === 'string' &&
typeOptions.some((o) => o.value === route.query.type)
) {
type.value = route.query.type as ActivityType;
}
if (typeof route.query.title === 'string' && route.query.title) {
title.value = route.query.title;
}
if (typeof route.query.dueAt === 'string' && route.query.dueAt) {
splitDue(new Date(route.query.dueAt).toISOString());
}
}
} catch {
error.value = 'بارگذاری فرم فعالیت ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateActivityRequest {
const due =
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
const reminder = reminderAt.value ? new Date(reminderAt.value) : null;
return {
title: title.value,
type: type.value || 'task',
priority: priority.value,
description: description.value || undefined,
dueAt: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
durationMinutes: durationMinutes.value || null,
reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null,
reminderNotifyApp: reminder ? reminderNotifyApp.value : false,
reminderNotifySms: reminder ? reminderNotifySms.value : false,
personId: personId.value || null,
companyId: selectedPerson.value?.company?.id ?? null,
dealId: dealId.value || null,
assigneeId: assigneeId.value || null,
};
}
function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.';
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
return 'برای یادآوری، اعلان یا پیامک را انتخاب کنید.';
}
return '';
}
async function submit(next: 'detail' | 'new' = 'detail') {
const message = validate();
if (message) {
error.value = message;
if (needsNote.value && !description.value.trim()) tab.value = 'notes';
else tab.value = 'main';
return;
}
saving.value = true;
error.value = '';
try {
let savedId = id.value;
if (isNew.value) {
const { data } = await api.post<ActivityDto>('/activities', payload());
savedId = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
}
} else {
await api.patch(`/activities/${id.value}`, {
...payload(),
status: isDone.value ? 'done' : 'open',
});
}
if (next === 'new') {
if (!isNew.value) {
await router.push({ name: 'activity-new' });
}
resetForm();
return;
}
await router.replace({ name: 'activity-detail', params: { id: savedId } });
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) error.value = 'اتصال به سرور برقرار نشد.';
else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
function bumpDuration(delta: number) {
durationMinutes.value = Math.min(24 * 60, Math.max(5, durationMinutes.value + delta));
}
function cancel() {
if (!isNew.value && id.value) {
void router.push({ name: 'activity-detail', params: { id: id.value } });
return;
}
void router.push({ name: 'activities' });
}
watch([id, isNew], () => void load(), { immediate: true });
</script>
<template>
<section class="page" @click="typeOpen = assigneeOpen = personOpen = false">
<header class="desktop-head">
<p class="crumb">
<RouterLink :to="{ name: 'activities' }">فعالیت‌ها</RouterLink>
<span>‹</span>
<strong>{{ pageTitle }}</strong>
</p>
<div class="desk-actions">
<button class="btn primary" type="button" :disabled="saving" @click="submit('detail')">
<UiIcon name="check-plain" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
</button>
<button class="btn outline" type="button" :disabled="saving" @click="submit('new')">
<UiIcon name="plus" :size="16" />
ذخیره و ایجاد جدید
</button>
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">
<UiIcon name="x" :size="16" />
انصراف
</button>
</div>
</header>
<header class="mobile-head">
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit('detail')">
<UiIcon name="check-plain" :size="22" />
</button>
<h1>{{ pageTitle }}</h1>
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
<UiIcon name="x" :size="20" />
</button>
</header>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'main' }" @click="tab = 'main'">اطلاعات اصلی</button>
<button type="button" :class="{ on: tab === 'notes' }" @click="tab = 'notes'">توضیحات و یادداشت</button>
</nav>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="layout">
<form class="form" @submit.prevent="submit('detail')">
<div v-show="tab === 'main'" class="stack">
<div class="card pair">
<div class="field">
<label>نوع فعالیت <i>*</i></label>
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen">
<span class="type-ico" :class="type || 'empty'">
<UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.label || 'انتخاب کنید' }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="typeOpen" class="menu">
<li v-for="opt in typeOptions" :key="opt.value">
<button type="button" @click="type = opt.value; typeOpen = false">
<span class="type-ico" :class="opt.value">
<UiIcon :name="opt.icon" :size="14" />
</span>
{{ opt.label }}
</button>
</li>
</ul>
</div>
<div class="field">
<label for="title">عنوان فعالیت <i>*</i></label>
<input
id="title"
v-model="title"
required
minlength="2"
placeholder="مثلاً: تماس پیگیری جهت ارائه پیش‌فاکتور"
/>
</div>
</div>
<div class="card toggles">
<label class="switch">
<span>این فعالیت انجام شده</span>
<input v-model="isDone" type="checkbox" />
<i />
</label>
<label class="switch">
<span>این فعالیت نیاز به توضیح دارد</span>
<input v-model="needsNote" type="checkbox" />
<i />
</label>
</div>
<div class="card trio">
<div class="field">
<label>تاریخ <i>*</i></label>
<JalaliDateInput v-model="dueDate" required />
</div>
<div class="field">
<label>ساعت <i>*</i></label>
<JalaliDateInput v-model="dueTime" mode="time" required />
</div>
<div class="field">
<label>مدت فعالیت (دقیقه) <i>*</i></label>
<div class="stepper">
<button type="button" aria-label="افزایش" @click="bumpDuration(5)">
<UiIcon name="plus" :size="16" />
</button>
<strong>{{ toFaDigits(durationMinutes) }}</strong>
<button type="button" aria-label="کاهش" @click="bumpDuration(-5)">
<UiIcon name="minus" :size="16" />
</button>
</div>
</div>
</div>
<div class="card">
<div class="field">
<label>مسئول انجام این فعالیت <i>*</i></label>
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
<UserAvatar :name="selectedAssignee?.fullName || 'مسئول'" :size="32" />
<span :class="{ ghost: !selectedAssignee }">
{{ selectedAssignee?.fullName || 'انتخاب مسئول' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="assigneeOpen" class="menu">
<li v-for="user in users" :key="user.id">
<button type="button" @click="assigneeId = user.id; assigneeOpen = false">
<UserAvatar :name="user.fullName" :size="28" />
{{ user.fullName }}
</button>
</li>
</ul>
</div>
</div>
<div class="card">
<div class="field">
<label>شخص / مشتری مرتبط <i>*</i></label>
<button class="picker person-pick" type="button" @click.stop="personOpen = !personOpen">
<UserAvatar :name="selectedPerson?.fullName || 'مشتری'" src="" :size="32" />
<span class="who">
<b :class="{ ghost: !selectedPerson }">{{ selectedPerson?.fullName || 'انتخاب کنید' }}</b>
<small v-if="personSub">{{ personSub }}</small>
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="personOpen" class="menu">
<li v-for="p in people" :key="p.id">
<button type="button" @click="personId = p.id; personOpen = false">
<UserAvatar :name="p.fullName" src="" :size="28" />
<span class="who">
<b>{{ p.fullName }}</b>
<small>{{ p.company?.name || '' }}</small>
</span>
</button>
</li>
</ul>
</div>
<div v-if="personDeals.length > 1" class="field deal-field">
<label>فرصت مرتبط</label>
<select v-model="dealId">
<option value="">بدون معامله</option>
<option v-for="d in personDeals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</div>
</div>
<div class="card">
<p class="pri-label">اولویت</p>
<div class="priorities">
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]">
<input v-model="priority" type="radio" :value="opt.value" />
<UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span>
</label>
</div>
</div>
<div class="card reminder">
<JalaliDateInput
v-model="reminderAt"
mode="datetime"
icon="bell"
placeholder="یادآوری تنظیم نشده است"
/>
<div v-if="reminderAt" class="reminder-channels">
<p class="channel-title">چگونه یادآوری شود؟</p>
<label class="channel">
<input v-model="reminderNotifyApp" type="checkbox" />
<span>
<strong>اعلان داخل پنل</strong>
<small>در زنگوله اعلان‌های منتو نشان داده می‌شود</small>
</span>
</label>
<label class="channel">
<input v-model="reminderNotifySms" type="checkbox" />
<span>
<strong>پیامک به مسئول</strong>
<small>به شماره موبایل مسئول فعالیت ارسال می‌شود</small>
</span>
</label>
<p
v-if="reminderAt && !reminderNotifyApp && !reminderNotifySms"
class="channel-warn"
>
حداقل یکی از روش‌های یادآوری را انتخاب کنید.
</p>
</div>
</div>
</div>
<div v-show="tab === 'notes'" class="card note-card">
<div class="note-head">
<label for="description">توضیحات و یادداشت</label>
<VoiceToTextButton
@transcript="(text) => (description = appendSpokenText(description, text))"
/>
</div>
<textarea
id="description"
v-model="description"
rows="8"
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
</form>
<aside class="summary">
<h2>
<UiIcon name="document" :size="18" />
خلاصه فعالیت
</h2>
<div v-if="!summaryReady" class="empty">
<UiIcon name="calendar" :size="28" />
<p>با تکمیل اطلاعات، خلاصه فعالیت در اینجا نمایش داده می‌شود.</p>
</div>
<dl>
<div>
<dt>نوع فعالیت</dt>
<dd>{{ typeMeta?.label || '—' }}</dd>
</div>
<div>
<dt>تاریخ و ساعت</dt>
<dd>{{ duePreview }}</dd>
</div>
<div>
<dt>مدت فعالیت</dt>
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
</div>
<div>
<dt>مسئول</dt>
<dd>{{ selectedAssignee?.fullName || '—' }}</dd>
</div>
<div>
<dt>مشتری مرتبط</dt>
<dd>{{ selectedPerson?.fullName || '—' }}</dd>
</div>
<div>
<dt>وضعیت انجام</dt>
<dd>{{ statusText }}</dd>
</div>
<div>
<dt>اولویت</dt>
<dd>{{ priorityText }}</dd>
</div>
</dl>
</aside>
</div>
<footer class="mobile-save">
<button class="btn primary" type="button" :disabled="saving || loading" @click="submit('detail')">
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
</button>
</footer>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 0.85rem;
padding-bottom: 5.5rem;
}
.desktop-head {
display: none;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.crumb {
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
color: #64748b;
}
.crumb a {
color: #0f766e;
font-weight: 700;
}
.desk-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mobile-head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
}
.mobile-head h1 {
margin: 0;
text-align: center;
font-size: 1.05rem;
}
.icon-btn {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.tabs {
display: flex;
gap: 1.2rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
border: 0;
background: none;
padding: 0.55rem 0;
font: inherit;
color: #94a3b8;
cursor: pointer;
}
.tabs .on {
color: #0f766e;
font-weight: 800;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.layout {
display: grid;
gap: 0.85rem;
}
.summary {
display: none;
}
.form,
.stack {
display: grid;
gap: 0.75rem;
}
.card {
position: relative;
background: #fff;
border-radius: 18px;
padding: 0.95rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.field {
display: grid;
gap: 0.4rem;
min-width: 0;
}
.field label,
.pri-label {
margin: 0;
color: #64748b;
font-size: 0.82rem;
}
.field i {
color: #dc2626;
font-style: normal;
}
.field input,
.field select,
.field textarea,
.note-card textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.75rem 0.85rem;
font: inherit;
background: #fff;
}
.picker {
width: 100%;
min-height: 48px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.picker .ghost,
.who .ghost {
color: #94a3b8;
font-weight: 500;
}
.picker :deep(.ico:last-child) {
margin-inline-start: auto;
color: #94a3b8;
transform: rotate(90deg);
}
.type-ico {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0d9488;
flex-shrink: 0;
}
.type-ico.meeting {
background: #f5f3ff;
color: #7c3aed;
}
.type-ico.task {
background: #fff7ed;
color: #c2410c;
}
.type-ico.note {
background: #eff6ff;
color: #2563eb;
}
.who {
display: grid;
min-width: 0;
text-align: start;
}
.who b {
font-weight: 800;
}
.who small {
color: #94a3b8;
font-size: 0.75rem;
}
.menu {
position: absolute;
inset-inline: 0.95rem;
top: calc(100% - 0.35rem);
z-index: 5;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 16rem;
overflow: auto;
}
.menu button {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: 8px;
font: inherit;
text-align: start;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.toggles {
display: grid;
gap: 0.85rem;
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
cursor: pointer;
}
.switch input {
display: none;
}
.switch i {
width: 44px;
height: 26px;
border-radius: 999px;
background: #e2e8f0;
position: relative;
flex-shrink: 0;
}
.switch i::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
transition: 0.18s;
}
.switch input:checked + i {
background: #14b8a6;
}
.switch input:checked + i::after {
inset-inline-start: 21px;
}
.trio {
display: grid;
gap: 0.85rem;
}
.stepper {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.2rem;
}
.stepper button {
width: 40px;
height: 40px;
border: 0;
border-radius: 10px;
background: #f0fdfa;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.stepper strong {
font-size: 1.05rem;
}
.deal-field {
margin-top: 0.75rem;
}
.priorities {
display: flex;
justify-content: space-between;
gap: 0.25rem;
margin-top: 0.45rem;
}
.pri {
display: grid;
justify-items: center;
gap: 0.2rem;
font-size: 0.68rem;
color: #64748b;
cursor: pointer;
}
.pri input {
accent-color: #14b8a6;
}
.pri.very-high {
color: #dc2626;
}
.pri.high {
color: #ea580c;
}
.pri.medium {
color: #0d9488;
}
.pri.low {
color: #2563eb;
}
.pri.very-low {
color: #94a3b8;
}
.pri.on span {
font-weight: 800;
}
.reminder :deep(.trigger) {
border: 0;
min-height: 48px;
padding-inline: 0.15rem;
}
.reminder-channels {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed #e2e8f0;
}
.channel-title {
margin: 0;
font-size: 0.88rem;
font-weight: 800;
color: #0f172a;
}
.channel {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
padding: 0.55rem 0.65rem;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.channel:has(input:checked) {
border-color: #99f6e4;
background: #f0fdfa;
}
.channel input {
margin-top: 0.2rem;
accent-color: #0d9488;
}
.channel span {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.channel strong {
font-size: 0.9rem;
color: #0f172a;
}
.channel small {
color: #64748b;
font-size: 0.78rem;
line-height: 1.45;
}
.channel-warn {
margin: 0;
color: #b45309;
font-size: 0.8rem;
}
.note-card {
display: grid;
gap: 0.45rem;
}
.note-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.note-card textarea {
min-height: 12rem;
resize: vertical;
}
.muted,
.error {
margin: 0;
}
.error {
color: #b91c1c;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
border-radius: 12px;
padding: 0.55rem 0.95rem;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.btn.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.btn.outline {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.btn.cancel {
border: 1.5px solid #f87171;
background: #fff;
color: #dc2626;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobile-save {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
z-index: 8;
}
.mobile-save .btn {
width: 100%;
min-height: 50px;
border-radius: 14px;
}
@media (min-width: 960px) {
.page {
padding-bottom: 1rem;
gap: 1rem;
}
.desktop-head {
display: flex;
}
.mobile-head,
.mobile-save {
display: none;
}
.layout {
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
align-items: start;
gap: 1rem;
}
.summary {
display: block;
position: sticky;
top: 0.8rem;
background: #fff;
border-radius: 20px;
padding: 1rem 1.05rem 0.7rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.summary h2 {
margin: 0 0 0.8rem;
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.95rem;
color: #0f766e;
}
.empty {
display: grid;
justify-items: center;
gap: 0.45rem;
padding: 1rem 0.6rem;
border-radius: 14px;
background: #f8fafc;
color: #94a3b8;
text-align: center;
font-size: 0.78rem;
margin-bottom: 0.7rem;
}
.empty p {
margin: 0;
}
.summary dl {
margin: 0;
}
.summary dl > div {
display: flex;
justify-content: space-between;
gap: 0.6rem;
padding: 0.62rem 0;
border-top: 1px solid #f1f5f9;
font-size: 0.8rem;
}
.summary dt {
color: #94a3b8;
}
.summary dd {
margin: 0;
font-weight: 700;
}
.pair,
.toggles,
.trio {
grid-template-columns: 1fr 1fr;
align-items: end;
}
.trio {
grid-template-columns: 1fr 1fr 1fr;
}
.toggles {
align-items: center;
}
.toggles .switch {
justify-content: flex-start;
}
.priorities {
justify-content: flex-start;
gap: 1.1rem;
}
}
</style>