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

1404 lines
36 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, onMounted, 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 { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import type {
CompanyDto,
CreateDealRequest,
DealDto,
DealLineItemInput,
OrganizationDto,
OrgUserDto,
PersonDto,
PipelineBoardDto,
PipelineDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
type MenuKey = 'person' | 'company' | 'owner' | 'currency' | '';
type ContactKind = 'person' | 'company';
type DealHealth = 'normal' | 'stale' | 'rotten';
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 === 'deal-new');
const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدید' : 'ویرایش معامله'));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const tab = ref<'main' | 'notes'>('main');
const showOthers = ref(true);
const productsOpen = ref(false);
const openMenu = ref<MenuKey>('');
const contactKind = ref<ContactKind>('person');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const stages = ref<PipelineBoardDto['stages']>([]);
const users = ref<OrgUserDto[]>([]);
const title = ref('');
const amount = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const source = ref('');
const notes = ref('');
const tagsRaw = ref('');
const health = ref<DealHealth>('normal');
const expectedCloseAt = ref('');
const personId = ref('');
const companyId = ref('');
const stageId = ref('');
const pipelineId = ref('');
const ownerId = ref('');
const lostReason = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
const lines = ref<LineDraft[]>([]);
const nextDueDate = ref('');
const nextDueTime = ref('10:30');
const nextDuration = ref(30);
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedCompany = computed(() => companies.value.find((c) => c.id === companyId.value) ?? null);
const selectedOwner = computed(() => users.value.find((u) => u.id === ownerId.value) ?? null);
const linesTotal = computed(() =>
lines.value.reduce((sum, line) => {
const q = Number(line.quantity);
const p = Number(line.unitPrice);
if (Number.isNaN(q) || Number.isNaN(p)) return sum;
return sum + q * p;
}, 0),
);
const productsLabel = computed(() =>
lines.value.length ? `${toFaDigits(lines.value.length)} محصول` : 'بدون محصول',
);
const currencyLabel = computed(() => currencySuffix(currency.value));
watch(personId, (value) => {
if (!value) return;
const person = people.value.find((p) => p.id === value);
if (person?.company?.id && !companyId.value) companyId.value = person.company.id;
});
function parseTags(raw: string) {
return raw
.split(/[,،#\n]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function addLine() {
productsOpen.value = true;
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
function bumpDuration(delta: number) {
nextDuration.value = Math.max(5, nextDuration.value + delta);
}
function toggleMenu(key: MenuKey) {
openMenu.value = openMenu.value === key ? '' : key;
}
function closeMenus() {
openMenu.value = '';
}
async function loadBoard(pid?: string) {
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pid ? { pipelineId: pid } : undefined,
});
stages.value = data.stages;
pipelineId.value = data.pipeline.id;
const keep = stages.value.some((s) => s.id === stageId.value);
if (!keep) {
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? '';
}
}
async function onPipelineChange(pid: string) {
pipelineId.value = pid;
await loadBoard(pid);
}
async function load() {
loading.value = true;
error.value = '';
try {
const queryPipelineId =
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : undefined;
const [peopleRes, companiesRes, pipelinesRes, productsRes, orgRes, usersRes] =
await Promise.all([
api.get<{ items: PersonDto[] }>('/people', { params: { limit: 100 } }),
api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 } }),
api.get<PipelineDto[]>('/pipelines'),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
pipelines.value = pipelinesRes.data;
products.value = productsRes.data;
users.value = usersRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
currency.value = orgRes.data.baseCurrency || 'IRR';
}
await loadBoard(queryPipelineId);
if (!isNew.value && id.value) {
const { data } = await api.get<DealDto>(`/deals/${id.value}`);
title.value = data.title;
amount.value = data.amount == null ? '' : String(data.amount);
currency.value = data.currency || currency.value;
source.value = data.source ?? '';
notes.value = data.notes ?? '';
tagsRaw.value = (data.tags ?? []).join('، ');
health.value = data.isRotten ? 'rotten' : data.isStale ? 'stale' : 'normal';
expectedCloseAt.value = data.expectedCloseAt
? data.expectedCloseAt.slice(0, 10)
: '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
contactKind.value = data.person?.id || !data.company?.id ? 'person' : 'company';
stageId.value = data.stageId;
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
lostReason.value = data.lostReason ?? '';
status.value = data.status;
customFields.value = { ...(data.customFields ?? {}) };
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
productsOpen.value = lines.value.length > 0;
if (data.pipelineId !== pipelineId.value) {
await loadBoard(data.pipelineId);
stageId.value = data.stageId;
}
} else {
ownerId.value = auth.user?.id ?? users.value[0]?.id ?? '';
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
contactKind.value = 'person';
const person = people.value.find((p) => p.id === personId.value);
if (person?.company?.id) companyId.value = person.company.id;
}
if (typeof route.query.companyId === 'string' && route.query.companyId) {
companyId.value = route.query.companyId;
if (!personId.value) contactKind.value = 'company';
}
}
} catch {
error.value = 'بارگذاری فرم معامله ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateDealRequest {
const lineItems = toLineInputs();
return {
title: title.value,
amount:
lineItems.length > 0
? linesTotal.value
: amount.value === ''
? null
: Number(amount.value),
source: source.value || undefined,
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
isStale: health.value === 'stale',
isRotten: health.value === 'rotten',
expectedCloseAt: expectedCloseAt.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
ownerId: ownerId.value || null,
pipelineId: pipelineId.value || undefined,
stageId: stageId.value || undefined,
customFields: customFields.value,
currency: currency.value || 'IRR',
lineItems,
};
}
async function createFollowUp(dealId: string) {
if (!isNew.value || !nextDueDate.value) return;
const due = new Date(`${nextDueDate.value}T${nextDueTime.value || '10:30'}:00`);
if (Number.isNaN(due.getTime())) return;
await api.post('/activities', {
title: `پیگیری: ${title.value}`,
type: 'task',
dueAt: due.toISOString(),
durationMinutes: nextDuration.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId,
assigneeId: ownerId.value || null,
});
}
function validate() {
if (title.value.trim().length < 2) return 'عنوان معامله را بنویسید.';
return '';
}
function cancel() {
void router.push({ name: 'pipeline' });
}
async function submit() {
const message = validate();
if (message) {
error.value = message;
tab.value = 'main';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<DealDto>('/deals', payload());
try {
await createFollowUp(data.id);
} catch {
/* deal saved; follow-up is optional */
}
await router.replace({ name: 'deal-detail', params: { id: data.id } });
} else {
await api.patch(`/deals/${id.value}`, {
...payload(),
lostReason: lostReason.value || null,
});
if (stageId.value) {
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
}
await router.push({ name: 'pipeline' });
}
} 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 openNotes() {
if (window.innerWidth < 960) tab.value = 'notes';
document.getElementById('notes')?.focus();
}
onMounted(load);
</script>
<template>
<section class="page" @click="closeMenus">
<header class="desktop-head">
<div class="title-block">
<span class="doc-ico">
<UiIcon name="document" :size="22" />
</span>
<div>
<h1>{{ pageTitle }}</h1>
<p class="crumb">
<RouterLink :to="{ name: 'pipeline' }">کاریز فروش</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</p>
</div>
</div>
<div class="desk-actions">
<RouterLink
v-if="!isNew && id"
class="btn outline"
:to="{ name: 'quote-new', query: { fromDealId: id } }"
>
صدور پیش‌فاکتور
</RouterLink>
<RouterLink
v-if="!isNew && id"
class="btn outline"
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
>
صدور فاکتور
</RouterLink>
<button class="btn primary" type="button" :disabled="saving" @click="submit">
<UiIcon name="check-plain" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</button>
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">
انصراف
</button>
</div>
</header>
<header class="mobile-head">
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit">
<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>
<form v-else class="form" @submit.prevent="submit">
<div v-show="tab === 'main'" class="stack">
<section class="card main-card">
<h2 class="sec">
<i class="dot" />
اطلاعات اصلی
</h2>
<div class="field kind-field">
<label>نوع شخص / شرکت <i>*</i></label>
<div class="segment">
<button
type="button"
:class="{ on: contactKind === 'person' }"
@click="contactKind = 'person'"
>
<UiIcon name="user" :size="16" />
شخص
</button>
<button
type="button"
:class="{ on: contactKind === 'company' }"
@click="contactKind = 'company'"
>
<UiIcon name="building" :size="16" />
شرکت
</button>
</div>
</div>
<div class="pair contacts">
<div v-show="contactKind === 'person'" class="field">
<label>شخص مرتبط <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('person')">
<UserAvatar :name="selectedPerson?.fullName || 'شخص'" src="" :size="32" />
<span :class="{ ghost: !selectedPerson }">
{{ selectedPerson?.fullName || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'person'" class="menu">
<li>
<button type="button" @click="personId = ''; openMenu = ''">بدون شخص</button>
</li>
<li v-for="p in people" :key="p.id">
<button type="button" @click="personId = p.id; openMenu = ''">
<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-show="contactKind === 'company'" class="field desktop-also">
<label>شرکت مرتبط <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('company')">
<span class="lead-ico">
<UiIcon name="building" :size="16" />
</span>
<span :class="{ ghost: !selectedCompany }">
{{ selectedCompany?.name || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'company'" class="menu">
<li>
<button type="button" @click="companyId = ''; openMenu = ''">بدون شرکت</button>
</li>
<li v-for="c in companies" :key="c.id">
<button type="button" @click="companyId = c.id; openMenu = ''">
<span class="lead-ico">
<UiIcon name="building" :size="14" />
</span>
{{ c.name }}
</button>
</li>
</ul>
</div>
</div>
<div class="field">
<label for="title">عنوان معامله <i>*</i></label>
<div class="control">
<input
id="title"
v-model="title"
required
minlength="2"
placeholder="مثال: فروش نرم‌افزار حسابداری منتک"
/>
<UiIcon name="briefcase" :size="18" />
</div>
</div>
<div class="pair">
<div class="field">
<label for="amount">پیش‌بینی مبلغ فروش به مشتری</label>
<div class="amount">
<input
id="amount"
v-model="amount"
type="number"
min="0"
step="1"
:readonly="lines.length > 0"
:placeholder="lines.length ? 'جمع ردیف‌ها' : 'مبلغ را وارد کنید'"
/>
<button class="cur-chip" type="button" @click.stop="toggleMenu('currency')">
{{ currencyLabel }}
<UiIcon name="chevron" :size="14" />
</button>
<ul v-if="openMenu === 'currency'" class="menu nested">
<li v-for="code in enabledCurrencies" :key="code">
<button type="button" @click="currency = code; openMenu = ''">
{{ currencySuffix(code) }}
</button>
</li>
</ul>
</div>
<small v-if="lines.length" class="hint">
جمع ردیف‌ها: {{ formatMoney(linesTotal, currency) }}
</small>
</div>
<div class="field">
<label>پیش‌بینی تاریخ فروش به مشتری</label>
<JalaliDateInput v-model="expectedCloseAt" placeholder="انتخاب تاریخ" />
</div>
</div>
<div class="schedule">
<div class="field next-field">
<label>فعالیت بعدی</label>
<JalaliDateInput v-model="nextDueDate" placeholder="انتخاب تاریخ پیگیری" />
</div>
<div class="pair follow-up">
<div class="field">
<label>ساعت <i>*</i></label>
<JalaliDateInput v-model="nextDueTime" mode="time" />
</div>
<div class="field">
<label>مدت فعالیت (دقیقه) <i>*</i></label>
<div class="stepper">
<button type="button" aria-label="کاهش" @click="bumpDuration(-5)">
<UiIcon name="minus" :size="16" />
</button>
<strong>{{ toFaDigits(nextDuration) }}</strong>
<button type="button" aria-label="افزایش" @click="bumpDuration(5)">
<UiIcon name="plus" :size="16" />
</button>
</div>
</div>
</div>
<div class="field owner-field">
<label>مسئول انجام این معامله <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('owner')">
<UserAvatar :name="selectedOwner?.fullName || 'مسئول'" :size="32" />
<span :class="{ ghost: !selectedOwner }">
{{ selectedOwner?.fullName || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'owner'" class="menu">
<li v-for="user in users" :key="user.id">
<button type="button" @click="ownerId = user.id; openMenu = ''">
<UserAvatar :name="user.fullName" :size="28" />
{{ user.fullName }}
</button>
</li>
</ul>
</div>
</div>
</section>
<section class="card extras">
<button class="extras-toggle" type="button" @click="showOthers = !showOthers">
سایر فیلدها
<UiIcon name="chevron" :size="16" :class="{ open: showOthers }" />
</button>
<div v-if="showOthers" class="extras-grid">
<label class="mini">
<span class="mini-ico"><UiIcon name="dollar" :size="16" /></span>
<span class="mini-copy">
<small>واحد ارزی</small>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ currencySuffix(code) }}
</option>
</select>
</span>
<UiIcon name="chevron" :size="14" />
</label>
<button class="mini" type="button" @click="productsOpen = !productsOpen">
<span class="mini-ico"><UiIcon name="box" :size="16" /></span>
<span class="mini-copy">
<small>لیست محصولات</small>
<strong>{{ productsLabel }}</strong>
</span>
<UiIcon name="chevron" :size="14" />
</button>
<label class="mini">
<span class="mini-ico"><UiIcon name="flag" :size="16" /></span>
<span class="mini-copy">
<small>کاریز</small>
<select :value="pipelineId" @change="onPipelineChange(($event.target as HTMLSelectElement).value)">
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</span>
<UiIcon name="chevron" :size="14" />
</label>
<label class="mini">
<span class="mini-ico"><UiIcon name="layers" :size="16" /></span>
<span class="mini-copy">
<small>مرحله کاریز</small>
<select v-model="stageId" required>
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</span>
<UiIcon name="chevron" :size="14" />
</label>
<label class="mini">
<span class="mini-ico"><UiIcon name="target" :size="16" /></span>
<span class="mini-copy">
<small>وضعیت معامله</small>
<select v-model="health">
<option value="normal">عادی</option>
<option value="stale">راکد</option>
<option value="rotten">فاسد</option>
</select>
</span>
<UiIcon name="chevron" :size="14" />
</label>
<label class="mini">
<span class="mini-ico"><UiIcon name="users" :size="16" /></span>
<span class="mini-copy">
<small>شیوه آشنایی</small>
<input v-model="source" placeholder="وب‌سایت، معرفی…" />
</span>
</label>
<label class="mini">
<span class="mini-ico"><UiIcon name="filter" :size="16" /></span>
<span class="mini-copy">
<small>برچسب‌ها</small>
<input v-model="tagsRaw" placeholder="سازمانی، فوری" />
</span>
</label>
<button class="mini notes-mini" type="button" @click="openNotes">
<span class="mini-ico"><UiIcon name="chat" :size="16" /></span>
<span class="mini-copy">
<small>توضیحات و یادداشت</small>
<strong>{{ notes.trim() ? 'ویرایش یادداشت' : 'افزودن یادداشت' }}</strong>
</span>
<UiIcon name="chevron" :size="14" />
</button>
</div>
<div v-if="showOthers && productsOpen" class="lines-block">
<div class="lines-head">
<h3>محصولات / خدمات</h3>
<button class="linkish" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select v-model="line.productId" @change="onPickProduct(line)">
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" />
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
<button class="linkish danger" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p v-if="!lines.length" class="muted">
می‌توانید فقط مبلغ کل را وارد کنید یا از کاتالوگ محصول ردیف اضافه کنید.
</p>
</div>
<div v-if="showOthers && !isNew" class="field lost">
<label for="lostReason">دلیل شکست (اگر از دست رفت)</label>
<input id="lostReason" v-model="lostReason" />
</div>
<CustomFieldsSection v-if="showOthers" entity-type="deal" v-model="customFields" />
</section>
</div>
<div class="card note-card" :class="{ off: tab !== 'notes' }">
<label for="notes">توضیحات و یادداشت</label>
<textarea
id="notes"
v-model="notes"
rows="8"
placeholder="جزئیات معامله، توافق‌ها یا یادداشت مرتبط را بنویسید…"
/>
</div>
<EntityTimeline
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
entity-type="deal"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="status" class="muted">
وضعیت: {{ status === 'won' ? 'برنده' : status === 'lost' ? 'از دست‌رفته' : 'باز' }}
</p>
</form>
<footer class="mobile-save">
<button class="btn outline" type="button" :disabled="saving" @click="cancel">انصراف</button>
<button class="btn primary" type="button" :disabled="saving || loading" @click="submit">
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</button>
</footer>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 0.85rem;
padding-bottom: 5.8rem;
min-width: 0;
}
.desktop-head {
display: none;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.title-block {
display: flex;
align-items: center;
gap: 0.75rem;
}
.doc-ico {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
.crumb {
margin: 0.2rem 0 0;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.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 {
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;
}
.form,
.stack {
display: grid;
gap: 0.75rem;
min-width: 0;
}
.card {
position: relative;
background: #fff;
border-radius: 18px;
padding: 0.95rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.sec {
display: none;
align-items: center;
gap: 0.45rem;
margin: 0 0 0.9rem;
font-size: 0.95rem;
color: #0f172a;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #14b8a6;
}
.kind-field {
display: grid;
}
.field {
display: grid;
gap: 0.4rem;
min-width: 0;
position: relative;
}
.field label {
margin: 0;
color: #64748b;
font-size: 0.82rem;
}
.field i {
color: #dc2626;
font-style: normal;
}
.pair,
.schedule {
display: grid;
gap: 0.85rem;
}
.field :deep(.trigger) {
min-height: 48px;
}
.control,
.amount,
.picker {
width: 100%;
min-height: 48px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.35rem 0.7rem;
}
.control input,
.amount input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.4rem 0;
}
.control :deep(.ico),
.picker :deep(.ico:last-child) {
color: #94a3b8;
flex-shrink: 0;
margin-inline-start: auto;
}
.picker :deep(.ico:last-child) {
transform: rotate(90deg);
}
.lead-ico {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0d9488;
flex-shrink: 0;
}
.picker {
font: inherit;
text-align: start;
cursor: pointer;
}
.picker .ghost {
color: #94a3b8;
font-weight: 500;
}
.amount {
position: relative;
padding-inline-end: 0.4rem;
}
.cur-chip {
display: inline-flex;
align-items: center;
gap: 0.15rem;
border: 0;
background: #f8fafc;
color: #0f766e;
border-radius: 10px;
padding: 0.35rem 0.55rem;
font: inherit;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
}
.cur-chip :deep(.ico) {
transform: rotate(90deg);
color: #94a3b8;
}
.segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
padding: 0.28rem;
border-radius: 14px;
background: #f8fafc;
}
.segment button {
min-height: 44px;
border: 0;
border-radius: 12px;
background: transparent;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font: inherit;
cursor: pointer;
}
.segment .on {
background: #ecfdf5;
color: #0f766e;
font-weight: 800;
}
.menu {
position: absolute;
inset-inline: 0;
top: calc(100% + 4px);
z-index: 6;
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.nested {
inset-inline: auto 0;
min-width: 8rem;
}
.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;
}
.who {
display: grid;
min-width: 0;
text-align: start;
}
.who b {
font-weight: 800;
}
.who small {
color: #94a3b8;
font-size: 0.75rem;
}
.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;
}
.extras-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: #f8fafc;
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
font-weight: 800;
color: #0f172a;
cursor: pointer;
}
.extras-toggle :deep(.ico) {
color: #0f766e;
transform: rotate(-90deg);
}
.extras-toggle :deep(.ico.open) {
transform: rotate(90deg);
}
.extras-grid {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
}
.mini {
min-height: 52px;
border: 1px solid #eef2f6;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.35rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
position: relative;
}
.mini-ico {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
background: #f0fdfa;
color: #0f766e;
flex-shrink: 0;
}
.mini-copy {
display: grid;
min-width: 0;
flex: 1;
}
.mini-copy small {
color: #94a3b8;
font-size: 0.72rem;
}
.mini-copy strong {
font-size: 0.9rem;
font-weight: 700;
}
.mini select,
.mini input {
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-weight: 700;
padding: 0;
appearance: none;
}
.mini :deep(.ico:last-child) {
color: #cbd5e1;
transform: rotate(90deg);
flex-shrink: 0;
}
.lines-block {
display: grid;
gap: 0.55rem;
margin-top: 0.85rem;
padding-top: 0.75rem;
border-top: 1px dashed #e2e8f0;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.lines-head h3 {
margin: 0;
font-size: 0.92rem;
}
.line-row {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
.line-row input,
.line-row select,
.lost input {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.65rem 0.75rem;
background: #fff;
font: inherit;
}
.lost {
margin-top: 0.75rem;
}
.linkish {
border: 0;
background: none;
color: #0f766e;
font: inherit;
cursor: pointer;
}
.linkish.danger {
color: #b91c1c;
}
.note-card {
display: grid;
gap: 0.45rem;
}
.note-card.off,
.timeline.hide {
display: none;
}
.note-card textarea {
min-height: 12rem;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.75rem 0.85rem;
font: inherit;
resize: vertical;
}
.hint,
.muted,
.error {
margin: 0;
}
.hint,
.muted {
color: #64748b;
font-size: 0.85rem;
}
.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 #e2e8f0;
background: #fff;
color: #64748b;
}
.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;
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 0.55rem;
}
.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,
.tabs,
.kind-field,
.follow-up,
.mobile-save {
display: none;
}
.note-card.off,
.timeline.hide {
display: grid;
}
.sec {
display: flex;
}
.main-card,
.extras {
padding: 1.15rem 1.2rem;
}
.pair {
grid-template-columns: 1fr 1fr;
}
.schedule {
grid-template-columns: 1fr 1fr;
}
.next-field {
grid-column: 1;
grid-row: 1;
}
.owner-field {
grid-column: 2;
grid-row: 1;
}
.contacts .field {
display: grid !important;
}
.next-field {
display: grid;
}
.extras-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.line-row {
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
align-items: center;
}
.note-card {
display: grid;
}
}
</style>