feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 deletions
+69 -102
View File
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
Checkbox as ACheckbox,
Col as ACol,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiField, UiInput, UiSelect } from '@/components/ui';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
} from '@mento/shared';
const { Title: ATypographyTitle } = Typography;
const props = defineProps<{
entityType: CustomFieldEntity;
modelValue: Record<string, unknown>;
@@ -46,6 +58,10 @@ function displayValue(key: string) {
return String(v);
}
function selectOptions(field: CustomFieldDefinitionDto) {
return (field.options ?? []).map((opt) => ({ value: opt, label: opt }));
}
watch(
() => props.entityType,
() => {
@@ -57,110 +73,61 @@ onMounted(load);
</script>
<template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
<div v-else-if="fields.length" class="custom-block">
<h3 class="custom-title">فیلدهای سفارشی</h3>
<div class="crm-form-grid-2">
<UiField
v-for="field in fields"
:key="field.id"
:label="field.label"
:required="field.required"
:for-id="`cf-${field.key}`"
>
<UiSelect
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</UiSelect>
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
<input
<a-spin v-if="loading" />
<template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی
</a-typography-title>
<a-row :gutter="[16, 0]">
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-form-item :label="field.label" :required="field.required">
<a-select
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
type="checkbox"
:checked="Boolean(props.modelValue[field.key])"
@change="
setValue(field.key, ($event.target as HTMLInputElement).checked)
"
allow-clear
:value="String(displayValue(field.key)) || undefined"
:options="selectOptions(field)"
placeholder="انتخاب کنید"
style="width: 100%"
@update:value="setValue(field.key, $event ?? '')"
/>
<span>فعال</span>
</label>
<UiInput
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
type="number"
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
@update:model-value="
setValue(
field.key,
$event === '' || $event == null ? null : Number($event),
)
"
/>
<a-checkbox
v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)"
>
فعال
</a-checkbox>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<a-input-number
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
:value="
props.modelValue[field.key] != null
? Number(props.modelValue[field.key])
: undefined
"
style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))"
/>
<UiInput
v-else
:id="`cf-${field.key}`"
type="text"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
/>
</UiField>
</div>
</div>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<a-input
v-else
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</template>
<style scoped>
.custom-block {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-4);
border-top: 1px dashed var(--line);
width: 100%;
}
.custom-title {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
color: var(--muted);
font-size: var(--fs-xs);
}
.cf-check {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h-md);
font-size: var(--fs-base);
font-weight: var(--fw-medium);
color: var(--ink);
cursor: pointer;
}
.cf-check input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
</style>
+110 -197
View File
@@ -1,6 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Input as AInput,
List as AList,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
@@ -11,6 +29,9 @@ import type {
PaginatedResponse,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const props = defineProps<{
entityType: 'person' | 'company' | 'deal';
entityId: string;
@@ -187,210 +208,102 @@ onMounted(load);
</script>
<template>
<section class="timeline">
<header class="head">
<h3>تایم‌لاین</h3>
<p class="muted">یادداشت‌ها و فعالیت‌های مرتبط با این پرونده</p>
</header>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">تایم‌لاین</a-typography-title>
<a-typography-text type="secondary">
یادداشت‌ها و فعالیت‌های مرتبط با این پرونده
</a-typography-text>
<div class="composers">
<div class="composer">
<textarea
v-model="noteDraft"
rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
<a-textarea
v-model:value="noteDraft"
:rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<VoiceToTextButton
@transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))"
/>
<div class="compose-actions">
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
</div>
<div class="composer">
<input
v-model="activityDraft"
type="text"
<a-button
type="primary"
:disabled="saving || !noteDraft.trim()"
:loading="saving"
@click="addNote"
>
ثبت یادداشت
</a-button>
</a-flex>
<a-space-compact block style="width: 100%">
<a-input
v-model:value="activityDraft"
placeholder="عنوان فعالیت / پیگیری…"
@keydown.enter.prevent="addActivity"
style="width: 100%"
@pressEnter="addActivity"
/>
<button
class="btn btn-ghost"
type="button"
<a-button
:disabled="saving || !activityDraft.trim()"
@click="addActivity"
>
افزودن فعالیت
</button>
</div>
</div>
</a-button>
</a-space-compact>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="list">
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]">
<div class="item-main">
<span class="badge">{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
<p v-if="item.body" class="body">{{ item.body }}</p>
<time>{{ formatTime(item.at) }}</time>
</div>
<div class="item-actions">
<RouterLink :to="item.href">باز کردن</RouterLink>
<button
v-if="item.kind === 'activity' && !item.done"
class="linkish"
type="button"
@click="completeActivity(item)"
>
انجام شد
</button>
</div>
</li>
</ul>
<p v-else class="muted">هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p>
</section>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<a-spin :spinning="loading">
<a-list
v-if="items.length"
item-layout="horizontal"
:data-source="items"
:style="{ marginTop: '16px' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space :size="8">
<a-tag :bordered="false">{{ item.meta }}</a-tag>
<span>{{ item.title }}</span>
</a-space>
</template>
<template #description>
<a-typography-paragraph
v-if="item.body"
type="secondary"
:style="{ marginBottom: '4px' }"
>
{{ item.body }}
</a-typography-paragraph>
<a-typography-text type="secondary">{{ formatTime(item.at) }}</a-typography-text>
</template>
</a-list-item-meta>
<template #actions>
<RouterLink :to="item.href">باز کردن</RouterLink>
<a-button
v-if="item.kind === 'activity' && !item.done"
type="link"
@click="completeActivity(item)"
>
انجام شد
</a-button>
</template>
</a-list-item>
</template>
</a-list>
<a-empty
v-else-if="!loading"
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</a-spin>
</a-card>
</template>
<style scoped>
.timeline {
margin-top: 1.25rem;
padding-top: 1.1rem;
border-top: 1px solid var(--line);
display: grid;
gap: 0.9rem;
}
.head h3 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.head .muted {
margin: 0;
}
.composers {
display: grid;
gap: 0.75rem;
}
.composer {
display: grid;
gap: 0.5rem;
}
.composer textarea,
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
background: #fff;
}
.compose-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
@media (max-width: 640px) {
.item {
flex-direction: column;
}
.item-actions {
justify-items: start;
flex-direction: row;
display: flex;
gap: 0.75rem;
}
}
.item.note {
border-right: 3px solid var(--accent);
}
.item.activity {
border-right: 3px solid #b7791f;
}
.item.done {
opacity: 0.72;
}
.item-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.badge {
font-size: 0.75rem;
color: var(--muted);
}
.body {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
time {
font-size: 0.78rem;
color: var(--muted);
}
.item-actions {
display: grid;
gap: 0.35rem;
justify-items: end;
flex-shrink: 0;
}
.item-actions a,
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0;
}
.error {
color: var(--danger);
margin: 0;
}
.muted {
color: var(--muted);
margin: 0;
}
</style>
+96 -129
View File
@@ -13,7 +13,20 @@ import {
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
import {
Col as ACol,
Flex as AFlex,
Modal as AModal,
Progress as AProgress,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
previewPath: string;
@@ -48,7 +61,6 @@ const emit = defineEmits<{
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
@@ -77,11 +89,6 @@ const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
@@ -367,11 +374,13 @@ function apiMessage(e: unknown, fallback: string) {
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
void handleExcelFile(file as File);
return false;
};
async function handleExcelFile(file: File) {
if (props.disabled || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
@@ -413,7 +422,6 @@ async function onFile(ev: Event) {
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
@@ -443,126 +451,85 @@ async function confirmMapping() {
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<a-upload
:show-upload-list="false"
:disabled="disabled || submitting"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:before-upload="beforeUpload"
@click.stop
>
<slot :loading="submitting" :disabled="disabled || submitting" />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
</a-upload>
<UiBusyOverlay
<a-modal
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
:footer="null"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
>
<a-flex vertical align="center" :gap="16">
<a-spin />
<a-typography-text>{{ busyMessage }}</a-typography-text>
<a-progress
v-if="busyProgress != null"
:percent="busyProgress"
style="width: 100%"
/>
</a-flex>
</a-modal>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
<a-modal
:open="!!preview"
title="تطبیق ستون‌های اکسل"
:confirm-loading="submitting"
ok-text="ورود فایل"
cancel-text="انصراف"
:ok-button-props="{ disabled: !canImport }"
@ok="confirmMapping"
@cancel="closeMapper"
>
<a-typography-paragraph type="secondary">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
</a-typography-paragraph>
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
:disabled="submitting"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
</UiButton>
</template>
</UiModal>
</a-typography-paragraph>
<a-flex vertical :gap="12">
<a-row
v-for="col in preview?.columns ?? []"
:key="col.header"
:gutter="[16, 8]"
align="middle"
>
<a-col :xs="24" :md="12">
<a-typography-text strong>{{ col.header }}</a-typography-text>
<div v-if="col.samples.length">
<a-typography-text type="secondary">
نمونه: {{ col.samples.join('، ') }}
</a-typography-text>
</div>
</a-col>
<a-col :xs="24" :md="12">
<a-select
:value="mapping[col.header] ?? ''"
:disabled="submitting"
style="width: 100%"
:options="[
{ value: '', label: 'نادیده بگیر' },
...(preview?.fields ?? []).map((field) => ({
value: field.key,
label: field.required ? `${field.label} *` : field.label,
})),
]"
@update:value="(v) => setColumnField(col.header, String(v ?? ''))"
/>
</a-col>
</a-row>
</a-flex>
<a-typography-text v-if="!canImport" type="danger">
فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.
</a-typography-text>
</a-modal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,427 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
Button as AButton,
Flex as AFlex,
Modal as AModal,
Segmented as ASegmented,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import { PrinterOutlined } from '@ant-design/icons-vue';
import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money';
const { Text: ATypographyText } = Typography;
export type PreviewLine = {
name: string;
description?: string;
sku?: string;
quantity: number;
unitPrice: number;
total: number;
};
type PaperSize = 'A4' | 'A5';
const props = defineProps<{
open: boolean;
docMode: 'invoice' | 'quote' | 'credit';
orgName: string;
number: string;
title: string;
statusLabel: string;
issuedAt: string;
dueOrValid: string;
dueOrValidLabel: string;
customerName: string;
customerSub?: string;
notes: string;
currency: string;
lines: PreviewLine[];
subtotal: number;
discount: number;
taxAmount: number;
taxPercent: number;
payable: number;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const paperSize = ref<PaperSize>('A4');
const thStyle = {
padding: '7px 8px',
textAlign: 'right' as const,
color: '#6b7280',
fontWeight: '700',
fontSize: '11px',
borderBottom: '1px solid #e5e7eb',
};
const tdStyle = {
padding: '7px 8px',
textAlign: 'right' as const,
borderBottom: '1px solid #e5e7eb',
verticalAlign: 'top' as const,
};
watch(
() => props.open,
(open) => {
if (open) paperSize.value = 'A4';
},
);
const paperOptions = [
{ value: 'A4', label: 'A4' },
{ value: 'A5', label: 'A5' },
];
const docTypeLabel = computed(() => {
if (props.docMode === 'quote') return 'پیش‌فاکتور';
if (props.docMode === 'credit') return 'فاکتور برگشتی';
return 'فاکتور فروش';
});
const accent = '#10b981';
const displayNumber = computed(() => props.number?.trim() || 'پیش‌نویس');
const paperWidth = computed(() => (paperSize.value === 'A5' ? 520 : 720));
function faDate(value: string) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function close() {
emit('update:open', false);
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function money(n: number) {
return formatMoney(n, props.currency);
}
function printSheet() {
const size = paperSize.value;
const rowsHtml = props.lines.length
? props.lines
.map(
(line, index) => `<tr>
<td>${toFaDigits(index + 1)}</td>
<td>
<strong>${escapeHtml(line.name)}</strong>
${line.description ? `<div class="sub">${escapeHtml(line.description)}</div>` : ''}
</td>
<td class="ltr">${escapeHtml(line.sku || '—')}</td>
<td>${toFaDigits(line.quantity)}</td>
<td class="ltr">${escapeHtml(money(line.unitPrice))}</td>
<td class="ltr">${escapeHtml(money(line.total))}</td>
</tr>`,
)
.join('')
: `<tr><td colspan="6" class="empty">ردیفی ثبت نشده است</td></tr>`;
const win = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1200');
if (!win) return;
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(docTypeLabel.value)} ${escapeHtml(displayNumber.value)}</title>
<style>
@page { size: ${size}; margin: 12mm; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IRANSans", Tahoma, sans-serif;
color: #111827;
font-size: ${size === 'A5' ? '11px' : '12.5px'};
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.top { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.org { font-size: ${size === 'A5' ? '15px' : '18px'}; font-weight: 800; margin: 0; }
.doc { text-align: left; }
.doc-type { color: ${accent}; font-weight: 800; font-size: ${size === 'A5' ? '13px' : '15px'}; }
.num { font-weight: 700; direction: ltr; unicode-bidi: isolate; margin-top: 4px; }
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap: 8px 16px;
padding: 10px 0;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 14px;
}
.lbl { color: #6b7280; font-size: 11px; display: block; margin-bottom: 2px; }
.val { font-weight: 600; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 7px 8px; border-bottom: 1px solid #e5e7eb; text-align: right; vertical-align: top; }
th { color: #6b7280; font-weight: 700; font-size: 11px; background: #f9fafb; }
.sub { color: #6b7280; font-size: 11px; margin-top: 2px; }
.empty { text-align: center; color: #9ca3af; padding: 16px !important; }
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
.foot {
display: flex;
justify-content: space-between;
gap: 20px;
margin-top: 14px;
align-items: flex-start;
}
.notes { flex: 1; color: #374151; }
.totals { width: 240px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; }
.t-row { display: flex; justify-content: space-between; margin-bottom: 6px; }
.t-row.pay { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; font-weight: 800; color: ${accent}; }
.muted { color: #6b7280; }
</style>
</head>
<body>
<div class="top">
<div>
<p class="org">${escapeHtml(props.orgName || brand.nameFa)}</p>
<div class="muted">${escapeHtml(docTypeLabel.value)}</div>
</div>
<div class="doc">
<div class="doc-type">${escapeHtml(docTypeLabel.value)}</div>
<div class="num">${escapeHtml(displayNumber.value)}</div>
</div>
</div>
<div class="grid">
<div><span class="lbl">صادر برای</span><div class="val">${escapeHtml(props.customerName || '—')}</div>
${props.customerSub ? `<div class="sub">${escapeHtml(props.customerSub)}</div>` : ''}
</div>
<div><span class="lbl">عنوان</span><div class="val">${escapeHtml(props.title || docTypeLabel.value)}</div></div>
<div><span class="lbl">وضعیت</span><div class="val">${escapeHtml(props.statusLabel)}</div></div>
<div><span class="lbl">تاریخ صدور</span><div class="val">${escapeHtml(faDate(props.issuedAt))}</div></div>
<div><span class="lbl">${escapeHtml(props.dueOrValidLabel)}</span><div class="val">${escapeHtml(faDate(props.dueOrValid))}</div></div>
<div><span class="lbl">ارز</span><div class="val">${escapeHtml(currencySuffix(props.currency))} (${escapeHtml(props.currency)})</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>شرح</th>
<th>کد</th>
<th>تعداد</th>
<th>فی</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
<div class="foot">
<div class="notes">
${
props.notes?.trim()
? `<span class="lbl">یادداشت</span><div>${escapeHtml(props.notes)}</div>`
: ''
}
</div>
<div class="totals">
<div class="t-row"><span class="muted">جمع</span><span class="ltr">${escapeHtml(money(props.subtotal))}</span></div>
<div class="t-row"><span class="muted">تخفیف</span><span class="ltr">${escapeHtml(money(props.discount))}</span></div>
<div class="t-row"><span class="muted">مالیات</span><span class="ltr">${escapeHtml(money(props.taxAmount))}</span></div>
<div class="t-row pay"><span>قابل پرداخت</span><span class="ltr">${escapeHtml(money(props.payable))}</span></div>
</div>
</div>
</body>
</html>`;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => {
win.print();
win.close();
}, 250);
}
</script>
<template>
<a-modal
:open="open"
title="پیش‌نمایش سند"
:width="paperSize === 'A5' ? 640 : 840"
destroy-on-close
centered
:body-style="{ paddingTop: '12px' }"
@cancel="close"
>
<template #footer>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" style="width: 100%">
<a-segmented v-model:value="paperSize" :options="paperOptions" />
<a-space :size="8">
<a-button @click="close">بستن</a-button>
<a-button type="primary" @click="printSheet">
<template #icon><PrinterOutlined /></template>
چاپ {{ paperSize }}
</a-button>
</a-space>
</a-flex>
</template>
<a-flex justify="center" :style="{ background: '#f3f4f6', padding: '16px', borderRadius: '8px' }">
<div
:style="{
width: '100%',
maxWidth: `${paperWidth}px`,
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '4px',
padding: paperSize === 'A5' ? '18px 20px' : '24px 28px',
fontSize: paperSize === 'A5' ? '12px' : '13px',
color: '#111827',
}"
>
<a-flex justify="space-between" align="flex-start" :gap="12" :style="{ marginBottom: '14px' }">
<div>
<div :style="{ fontWeight: 800, fontSize: paperSize === 'A5' ? '15px' : '18px' }">
{{ orgName || brand.nameFa }}
</div>
<a-typography-text type="secondary">{{ docTypeLabel }}</a-typography-text>
</div>
<div :style="{ textAlign: 'left' }">
<div :style="{ color: accent, fontWeight: 800 }">{{ docTypeLabel }}</div>
<div dir="ltr" :style="{ fontWeight: 700, marginTop: '4px' }">{{ displayNumber }}</div>
</div>
</a-flex>
<div
:style="{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '8px 12px',
padding: '10px 0',
borderTop: '1px solid #e5e7eb',
borderBottom: '1px solid #e5e7eb',
marginBottom: '14px',
}"
>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">صادر برای</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ customerName || '—' }}</div>
<a-typography-text v-if="customerSub" type="secondary" :style="{ fontSize: '11px' }">
{{ customerSub }}
</a-typography-text>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">عنوان</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ title || docTypeLabel }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">وضعیت</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ statusLabel }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">تاریخ صدور</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ faDate(issuedAt) }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">{{ dueOrValidLabel }}</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ faDate(dueOrValid) }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">ارز</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ currencySuffix(currency) }} ({{ currency }})</div>
</div>
</div>
<table :style="{ width: '100%', borderCollapse: 'collapse' }">
<thead>
<tr :style="{ background: '#f9fafb' }">
<th :style="thStyle">#</th>
<th :style="thStyle">شرح</th>
<th :style="thStyle">کد</th>
<th :style="thStyle">تعداد</th>
<th :style="thStyle">فی</th>
<th :style="thStyle">مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-if="!lines.length">
<td colspan="6" :style="{ textAlign: 'center', color: '#9ca3af', padding: '16px' }">
ردیفی در فرم ثبت نشده است
</td>
</tr>
<tr v-for="(line, index) in lines" :key="`${line.name}-${index}`">
<td :style="tdStyle">{{ toFaDigits(index + 1) }}</td>
<td :style="tdStyle">
<div :style="{ fontWeight: 600 }">{{ line.name }}</div>
<div v-if="line.description" :style="{ color: '#6b7280', fontSize: '11px' }">
{{ line.description }}
</div>
</td>
<td :style="tdStyle" dir="ltr">{{ line.sku || '—' }}</td>
<td :style="tdStyle">{{ toFaDigits(line.quantity) }}</td>
<td :style="tdStyle" dir="ltr">{{ money(line.unitPrice) }}</td>
<td :style="tdStyle" dir="ltr">{{ money(line.total) }}</td>
</tr>
</tbody>
</table>
<a-flex justify="space-between" align="flex-start" :gap="16" :style="{ marginTop: '14px' }">
<div :style="{ flex: 1, minWidth: 0 }">
<template v-if="notes?.trim()">
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">یادداشت</a-typography-text>
<div>{{ notes }}</div>
</template>
</div>
<div
:style="{
width: paperSize === 'A5' ? '200px' : '240px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '10px 12px',
flexShrink: 0,
}"
>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">جمع</a-typography-text>
<span dir="ltr">{{ money(subtotal) }}</span>
</a-flex>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">تخفیف</a-typography-text>
<span dir="ltr">{{ money(discount) }}</span>
</a-flex>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">مالیات</a-typography-text>
<span dir="ltr">{{ money(taxAmount) }}</span>
</a-flex>
<a-flex
justify="space-between"
:style="{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #e5e7eb',
fontWeight: 800,
color: accent,
}"
>
<span>قابل پرداخت</span>
<span dir="ltr">{{ money(payable) }}</span>
</a-flex>
</div>
</a-flex>
</div>
</a-flex>
</a-modal>
</template>
+56 -64
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button as AButton, Input as AInput } from 'ant-design-vue';
import {
CalendarOutlined,
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import {
addJalaliMonths,
dateToJalali,
@@ -12,7 +19,6 @@ import {
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
@@ -39,7 +45,7 @@ const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const MOBILE_MQ = '(max-width: 720px)';
const open = ref(false);
const triggerRef = ref<HTMLButtonElement | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const clockRef = ref<HTMLElement | null>(null);
const hourColRef = ref<HTMLElement | null>(null);
@@ -439,41 +445,28 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="wrap" :class="{ disabled, 'time-only': mode === 'time' }">
<button
<div
ref="triggerRef"
class="wrap"
:class="{ disabled, 'time-only': mode === 'time' }"
style="width: 100%"
>
<a-input
:id="id"
ref="triggerRef"
class="trigger"
:class="{ 'clock-trigger': mode === 'time' }"
type="button"
readonly
:value="display"
:placeholder="emptyLabel"
:disabled="disabled"
:aria-expanded="open"
:aria-required="required || undefined"
style="width: 100%; cursor: pointer"
@click="toggle"
>
<template v-if="mode === 'time'">
<span class="clock-face" aria-hidden="true">
<i
class="hand hour-hand"
:style="{
transform: `rotate(${(draftHour % 12) * 30 + draftMinute * 0.5}deg)`,
}"
/>
<i
class="hand minute-hand"
:style="{ transform: `rotate(${draftMinute * 6}deg)` }"
/>
<i class="hub" />
</span>
<span class="clock-text" :class="{ empty: !display }">
{{ display || emptyLabel }}
</span>
<template #suffix>
<ClockCircleOutlined v-if="triggerIcon === 'clock'" />
<CalendarOutlined v-else />
</template>
<template v-else>
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
<UiIcon :name="triggerIcon" :size="18" />
</template>
</button>
</a-input>
<Teleport to="body">
<div
@@ -490,13 +483,13 @@ onBeforeUnmount(() => {
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<UiIcon name="chevron" :size="16" />
</button>
<a-button type="text" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<template #icon><RightOutlined /></template>
</a-button>
<strong>{{ monthTitle }}</strong>
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<UiIcon name="chevron" :size="16" />
</button>
<a-button type="text" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<template #icon><LeftOutlined /></template>
</a-button>
</header>
<template v-if="mode !== 'time'">
@@ -504,16 +497,16 @@ onBeforeUnmount(() => {
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div>
<div class="grid">
<button
<a-button
v-for="cell in cells"
:key="cell.key"
type="button"
type="text"
class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)"
>
{{ toFaDigits(cell.jd) }}
</button>
</a-button>
</div>
</template>
@@ -521,25 +514,25 @@ onBeforeUnmount(() => {
<template v-if="isMobile">
<p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p>
<div class="md-digits" dir="ltr">
<button
type="button"
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'hour' }"
aria-label="ساعت"
@click="clockFace = 'hour'"
>
{{ faPad(draftHour) }}
</button>
</a-button>
<span class="md-colon">:</span>
<button
type="button"
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'minute' }"
aria-label="دقیقه"
@click="clockFace = 'minute'"
>
{{ faPad(draftMinute) }}
</button>
</a-button>
</div>
<div
ref="clockRef"
@@ -561,10 +554,10 @@ onBeforeUnmount(() => {
</span>
<i class="md-hub" />
<template v-if="clockFace === 'hour'">
<button
<a-button
v-for="h in hours"
:key="`h-${h}`"
type="button"
type="text"
class="md-tick"
:class="{ on: draftHour === h, inner: h >= 12 }"
:style="hourDialStyle(h)"
@@ -572,13 +565,13 @@ onBeforeUnmount(() => {
@click.stop="setHour(h)"
>
{{ toFaDigits(h) }}
</button>
</a-button>
</template>
<template v-else>
<button
<a-button
v-for="m in clockMinutes"
:key="`m-${m}`"
type="button"
type="text"
class="md-tick"
:class="{ on: draftMinute === m }"
:style="minuteDialStyle(m)"
@@ -586,7 +579,7 @@ onBeforeUnmount(() => {
@click.stop="setMinute(m)"
>
{{ faPad(m) }}
</button>
</a-button>
</template>
</div>
</template>
@@ -596,31 +589,31 @@ onBeforeUnmount(() => {
<div class="col">
<span class="col-label">ساعت</span>
<div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت">
<button
<a-button
v-for="h in hours"
:key="`col-h-${h}`"
type="button"
type="text"
class="col-item"
:class="{ on: draftHour === h }"
@click="setHour(h)"
>
{{ faPad(h) }}
</button>
</a-button>
</div>
</div>
<div class="col">
<span class="col-label">دقیقه</span>
<div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه">
<button
<a-button
v-for="m in columnMinutes"
:key="`col-m-${m}`"
type="button"
type="text"
class="col-item"
:class="{ on: draftMinute === m }"
@click="setMinute(m)"
>
{{ faPad(m) }}
</button>
</a-button>
</div>
</div>
</div>
@@ -628,18 +621,17 @@ onBeforeUnmount(() => {
</div>
<footer class="foot">
<button type="button" class="link" @click="pickToday">
<a-button type="link" @click="pickToday">
{{ mode === 'time' ? 'الان' : 'امروز' }}
</button>
<button
</a-button>
<a-button
v-if="clearable && !required && model"
type="button"
class="link"
type="link"
@click="clear"
>
پاک کردن
</button>
<button type="button" class="done" @click="hide">تأیید</button>
</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
+95 -150
View File
@@ -1,10 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
List as AList,
Modal,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = defineProps<{
personId: string;
}>();
@@ -86,15 +103,24 @@ async function saveEdit(id: string) {
}
}
async function removeItem(id: string) {
if (!confirm('این مورد از حافظه حذف شود؟')) return;
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
}
function removeItem(id: string) {
Modal.confirm({
title: 'حذف از حافظه',
content: 'این مورد از حافظه حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function formatWhen(value: string) {
@@ -110,152 +136,71 @@ onMounted(load);
</script>
<template>
<section class="memory-panel">
<header>
<div>
<p class="eyebrow">{{ brand.memoryEn }}</p>
<h2>حافظه مشتری</h2>
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.</p>
</div>
</header>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-text type="secondary" strong>{{ brand.memoryEn }}</a-typography-text>
<a-typography-title :level="5" :style="{ margin: '4px 0' }">حافظه مشتری</a-typography-title>
<a-typography-paragraph type="secondary" :style="{ marginBottom: '12px' }">
نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.
</a-typography-paragraph>
<form class="add-form" @submit.prevent="addItem">
<textarea
v-model="draft"
rows="2"
<a-flex :gap="8" align="flex-start" :style="{ marginBottom: '12px' }">
<a-textarea
v-model:value="draft"
:rows="2"
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبح‌ها"
style="flex: 1"
@pressEnter.ctrl="addItem"
/>
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
<a-button
type="primary"
:disabled="saving || !draft.trim()"
:loading="saving"
@click="addItem"
>
افزودن
</button>
</form>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<ul v-else class="items">
<li v-for="item in items" :key="item.id">
<template v-if="editingId === item.id">
<textarea v-model="editContent" rows="2" />
<div class="row-actions">
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
ذخیره
</button>
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
</div>
<a-spin :spinning="loading">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
<a-list v-else-if="hasItems" item-layout="vertical" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<template v-if="editingId === item.id">
<a-flex vertical :gap="8" style="width: 100%">
<a-textarea v-model:value="editContent" :rows="2" />
<a-space :size="8">
<a-button type="primary" :loading="saving" @click="saveEdit(item.id)">ذخیره</a-button>
<a-button @click="cancelEdit">انصراف</a-button>
</a-space>
</a-flex>
</template>
<a-list-item-meta v-else>
<template #title>
<a-typography-paragraph :style="{ marginBottom: 0, whiteSpace: 'pre-wrap' }">
{{ item.content }}
</a-typography-paragraph>
</template>
<template #description>
<a-typography-text type="secondary">
{{ sourceLabel[item.sourceType] ?? item.sourceType }}
<template v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</template>
· {{ formatWhen(item.updatedAt) }}
</a-typography-text>
</template>
</a-list-item-meta>
<template v-if="editingId !== item.id" #actions>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<a-button type="link" danger @click="removeItem(item.id)">حذف</a-button>
</template>
</a-list-item>
</template>
<template v-else>
<p class="content">{{ item.content }}</p>
<p class="meta">
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
<span> · {{ formatWhen(item.updatedAt) }}</span>
</p>
<div class="row-actions">
<button type="button" @click="startEdit(item)">ویرایش</button>
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
</div>
</template>
</li>
</ul>
</section>
</a-list>
</a-spin>
</a-card>
</template>
<style scoped>
.memory-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
h2 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.lead {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
align-items: start;
}
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
resize: vertical;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.items li {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fff;
}
.content {
margin: 0;
white-space: pre-wrap;
}
.meta {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.row-actions button {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.row-actions button.danger {
color: #b42318;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
</style>
+12 -12
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { Input as AInput } from 'ant-design-vue';
import {
caretIndexForDigitCount,
countDigitChars,
@@ -34,7 +35,14 @@ const props = withDefaults(
const text = ref('');
const focused = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
const inputRef = ref<{ input?: HTMLInputElement } | HTMLInputElement | null>(null);
function nativeInput(): HTMLInputElement | null {
const el = inputRef.value;
if (!el) return null;
if (el instanceof HTMLInputElement) return el;
return el.input ?? null;
}
function readModelNumber(): number | null {
if (model.value === '' || model.value == null) return null;
@@ -83,7 +91,6 @@ async function onInput(event: Event) {
const n = parseMoneyNumber(raw, props.decimals);
if (n == null) {
// اجازهٔ تایپ ناقص مثل "-" یا خالی
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
text.value = cleaned;
writeModel(null);
@@ -99,7 +106,7 @@ async function onInput(event: Event) {
writeModel(n);
await nextTick();
const input = inputRef.value;
const input = nativeInput();
if (!input) return;
const nextCaret = caretIndexForDigitCount(display, digitsBefore);
input.setSelectionRange(nextCaret, nextCaret);
@@ -111,27 +118,20 @@ function normalizeSep(raw: string): string {
</script>
<template>
<input
<a-input
:id="id"
ref="inputRef"
class="money-input"
type="text"
inputmode="decimal"
autocomplete="off"
:dir="dir"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:value="text"
style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</template>
<style scoped>
.money-input {
font-variant-numeric: tabular-nums;
}
</style>
+119 -227
View File
@@ -4,8 +4,21 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
Avatar as AAvatar,
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
List as AList,
ListItem as AListItem,
Modal as AModal,
Space as ASpace,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import {
filterPhoneContacts,
isContactPickerSupported,
@@ -15,6 +28,8 @@ import {
type PhoneContactDraft,
} from '@/lib/phone-contacts';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = withDefaults(
defineProps<{
disabled?: boolean;
@@ -38,7 +53,6 @@ const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set());
const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => {
@@ -65,7 +79,6 @@ async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolea
drafts.value = sortPhoneContacts(rows);
listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
phase.value = 'closed';
await nextTick();
phase.value = 'review';
@@ -83,7 +96,6 @@ function selectAll(on: boolean) {
selected.value = new Set();
return;
}
// اگر جستجو فعال است فقط نتایج را انتخاب کن، وگرنه همه
if (listQuery.value.trim()) {
const next = new Set(selected.value);
for (const row of visibleDrafts.value) next.add(row.key);
@@ -121,7 +133,6 @@ async function runDevicePicker() {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false });
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
@@ -143,11 +154,12 @@ function startPicker() {
openHelp();
}
async function onVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
void handleVcfFile(file as File);
return false;
};
async function handleVcfFile(file: File) {
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
@@ -223,68 +235,70 @@ async function importSelected() {
</script>
<template>
<span class="wrap" @click.stop>
<span class="trigger" @click="startPicker">
<span @click.stop>
<span @click="startPicker">
<slot />
</span>
<input
ref="fileInput"
class="hidden"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onVcfFile"
/>
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<a-modal
:open="modalOpen"
:title="modalTitle"
:confirm-loading="importing"
@cancel="cancel"
>
<template v-if="phase === 'choose'">
<p class="lead">
<a-typography-paragraph type="secondary">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p>
<div class="choose-actions">
<UiButton variant="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" />
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block @click="runDevicePicker">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block @click="fileInput?.click()">
از فایل .vcf
</UiButton>
</div>
</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button block>از فایل .vcf</a-button>
</a-upload>
</a-flex>
</template>
<template v-else-if="phase === 'help'">
<p class="lead">
<a-typography-paragraph type="secondary">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</p>
<ul class="tips">
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
</ul>
</a-typography-paragraph>
<a-typography-paragraph type="secondary">
اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
Export → فایل vCard.
</a-typography-paragraph>
</template>
<template v-else-if="phase === 'review'">
<p class="lead">
<a-typography-paragraph type="secondary">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
<div class="search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">
</a-typography-paragraph>
<a-input
v-model:value="listQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
style="width: 100%; margin-bottom: 12px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-button type="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</a-button>
<a-button type="link" @click="selectAll(false)">هیچ‌کدام</a-button>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
@@ -292,194 +306,72 @@ async function importSelected() {
<template v-else>
از {{ toFaDigits(drafts.length) }}
</template>
</span>
</div>
<ul v-if="visibleDrafts.length" class="list">
<li v-for="row in visibleDrafts" :key="row.key">
<label>
<input
type="checkbox"
:checked="selected.has(row.key)"
</a-typography-text>
</a-flex>
<a-list
v-if="visibleDrafts.length"
:data-source="visibleDrafts"
:style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-checkbox
:checked="selected.has(item.key)"
:disabled="importing"
@change="toggle(row.key)"
/>
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</label>
</li>
</ul>
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
@click.prevent="toggle(item.key)"
>
<a-space :size="8">
<a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
<span>
<div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
<a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
</span>
</a-space>
</a-checkbox>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</template>
<template #footer>
<template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
<a-button @click="cancel">انصراف</a-button>
</template>
<template v-else-if="phase === 'help'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
انتخاب فایل .vcf
</UiButton>
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
</a-upload>
</template>
<template v-else-if="phase === 'review'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
فایل دیگر
</UiButton>
<UiButton
variant="primary"
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
:disabled="importing"
>
<a-button :disabled="importing">فایل دیگر</a-button>
</a-upload>
<a-button
type="primary"
:disabled="importing || !selectedCount"
:loading="importing"
@click="importSelected"
>
<UiIcon name="user-plus" :size="16" />
<template #icon><UserAddOutlined /></template>
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
</UiButton>
</a-button>
</template>
</template>
</UiModal>
</a-modal>
</span>
</template>
<style scoped>
.wrap {
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.hidden {
display: none;
}
.lead {
margin: 0 0 0.85rem;
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.choose-actions {
display: grid;
gap: 0.55rem;
}
.tips {
margin: 0;
padding: 0 1.1rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.8;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.84rem;
color: #0f172a;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.7rem;
flex-wrap: wrap;
}
.link {
border: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.78rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.list label {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
cursor: pointer;
}
.avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.meta strong {
font-size: 0.86rem;
}
.meta small {
color: #94a3b8;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
margin: 0.75rem 0 0;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
</style>
+105 -183
View File
@@ -1,37 +1,58 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Modal,
Modal as AModal,
Select as ASelect,
Space,
message,
} from 'ant-design-vue';
const ASpaceCompact = Space.Compact;
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
const props = defineProps<{
scope: SavedViewScope;
/** فیلتر فعلی صفحه برای ذخیره */
currentFilters: Record<string, unknown>;
}>();
const props = withDefaults(
defineProps<{
scope: SavedViewScope;
currentFilters: Record<string, unknown>;
/** فشرده برای قرار گرفتن داخل یک ردیف فیلتر */
compact?: boolean;
}>(),
{ compact: false },
);
const emit = defineEmits<{
apply: [filters: Record<string, unknown>];
}>();
const views = ref<SavedViewDto[]>([]);
const activeId = ref('');
const activeId = ref<string | undefined>(undefined);
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(false);
useEscClose(() => saveOpen.value, () => { saveOpen.value = false; });
const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null,
);
const selectOptions = computed(() =>
views.value.map((v) => ({
value: v.id,
label: `${v.name}${v.isShared ? ' (سازمانی)' : ''}`,
})),
);
async function loadViews() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<SavedViewDto[]>('/saved-views', {
params: { scope: props.scope },
@@ -39,16 +60,17 @@ async function loadViews() {
views.value = data;
} catch {
views.value = [];
error.value = 'بارگذاری فیلترهای ذخیره‌شده ناموفق بود.';
message.error('بارگذاری فیلترهای ذخیره‌شده ناموفق بود.');
} finally {
loading.value = false;
}
}
function onPick(id: string) {
activeId.value = id;
if (!id) return;
const view = views.value.find((v) => v.id === id);
function onPick(id: unknown) {
const next = typeof id === 'string' ? id : '';
activeId.value = next || undefined;
if (!next) return;
const view = views.value.find((v) => v.id === next);
if (view) emit('apply', view.filters ?? {});
}
@@ -56,14 +78,12 @@ function openSave() {
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? false;
saveOpen.value = true;
error.value = '';
}
async function saveView() {
const name = saveName.value.trim();
if (name.length < 2 || busy.value) return;
busy.value = true;
error.value = '';
try {
const body = {
name,
@@ -86,32 +106,42 @@ async function saveView() {
activeId.value = data.id;
}
saveOpen.value = false;
message.success('فیلتر ذخیره شد.');
} catch {
error.value = 'ذخیره فیلتر ناموفق بود.';
message.error('ذخیره فیلتر ناموفق بود.');
} finally {
busy.value = false;
}
}
async function removeView() {
function removeView() {
if (!activeId.value || busy.value) return;
if (!confirm('این فیلتر ذخیره‌شده حذف شود؟')) return;
busy.value = true;
try {
await api.delete(`/saved-views/${activeId.value}`);
views.value = views.value.filter((v) => v.id !== activeId.value);
activeId.value = '';
} catch {
error.value = 'حذف فیلتر ناموفق بود.';
} finally {
busy.value = false;
}
Modal.confirm({
title: 'حذف فیلتر',
content: 'این فیلتر ذخیره‌شده حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busy.value = true;
try {
await api.delete(`/saved-views/${activeId.value}`);
views.value = views.value.filter((v) => v.id !== activeId.value);
activeId.value = undefined;
message.success('فیلتر حذف شد.');
} catch {
message.error('حذف فیلتر ناموفق بود.');
} finally {
busy.value = false;
}
},
});
}
watch(
() => props.scope,
() => {
activeId.value = '';
activeId.value = undefined;
void loadViews();
},
);
@@ -122,161 +152,53 @@ onMounted(() => {
</script>
<template>
<div class="saved-filters" dir="rtl">
<label class="pick">
<span class="lbl">فیلتر ذخیره‌شده</span>
<select
:value="activeId"
:disabled="loading"
@change="onPick(($event.target as HTMLSelectElement).value)"
>
<option value="">— انتخاب —</option>
<option v-for="v in views" :key="v.id" :value="v.id">
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
</option>
</select>
</label>
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
<UiIcon name="flag" :size="14" />
<!-- یک خط فشرده: سلکت + ذخیره (+ حذف) -->
<a-space-compact block dir="rtl" :style="{ width: '100%' }">
<a-select
:value="activeId"
allow-clear
:disabled="loading"
:loading="loading"
:options="selectOptions"
:placeholder="compact ? 'فیلتر ذخیره‌شده' : '— انتخاب —'"
:style="{ flex: 1, minWidth: 0, width: '100%' }"
@update:value="onPick"
/>
<a-button :disabled="loading" title="ذخیره فیلتر فعلی" @click="openSave">
<template #icon><FlagOutlined /></template>
ذخیره
</button>
<button
</a-button>
<a-button
v-if="activeId"
type="button"
class="btn danger"
title="حذف فیلتر"
danger
:disabled="busy"
title="حذف فیلتر"
@click="removeView"
>
حذف
</button>
<p v-if="error" class="err">{{ error }}</p>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-space-compact>
<div v-if="saveOpen" class="sheet">
<div class="sheet-backdrop" aria-hidden="true" />
<div class="sheet-panel" role="dialog" aria-modal="true">
<header class="sheet-head">
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
<button type="button" class="x-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="saveOpen = false">
<UiIcon name="x" :size="16" />
</button>
</header>
<label>
نام
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخ‌های این هفته" />
</label>
<label class="check">
<input v-model="saveShared" type="checkbox" />
اشتراک با سازمان
</label>
<div class="actions">
<button type="button" class="btn primary" :disabled="busy" @click="saveView">
{{ busy ? '…' : 'ذخیره' }}
</button>
<button type="button" class="btn" @click="saveOpen = false">لغو</button>
</div>
</div>
</div>
</div>
<a-modal
v-model:open="saveOpen"
:title="activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر'"
:confirm-loading="busy"
ok-text="ذخیره"
cancel-text="لغو"
destroy-on-close
@ok="saveView"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input
v-model:value="saveName"
:maxlength="120"
placeholder="مثلاً سرنخ‌های این هفته"
/>
</a-form-item>
<a-form-item :style="{ marginBottom: 0 }">
<a-checkbox v-model:checked="saveShared">اشتراک با سازمان</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.saved-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.45rem;
margin-top: 0.55rem;
}
.pick {
display: grid;
gap: 0.2rem;
min-width: 11rem;
}
.lbl {
font-size: 0.72rem;
color: #64748b;
}
select,
input[type='text'] {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
font-size: 0.85rem;
color: #0f172a;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 8px;
padding: 0.4rem 0.65rem;
font-size: 0.82rem;
cursor: pointer;
}
.btn.primary {
background: #0d9488;
border-color: #0d9488;
color: #fff;
}
.btn.danger {
color: #b91c1c;
}
.err {
width: 100%;
margin: 0;
font-size: 0.8rem;
color: #b91c1c;
}
.sheet {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
}
.sheet-panel {
position: relative;
z-index: 1;
width: min(100%, 360px);
background: #fff;
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
}
.sheet-panel h3 {
margin: 0;
font-size: 1rem;
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.x-btn { width: 32px; height: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #64748b; display: grid; place-items: center; cursor: pointer; }
.x-btn:hover { background: #f8fafc; color: #0f172a; }
.sheet-panel label {
display: grid;
gap: 0.3rem;
font-size: 0.85rem;
color: #334155;
}
.check {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.actions {
display: flex;
gap: 0.45rem;
}
</style>
+48 -154
View File
@@ -1,9 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, ref } from 'vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Popover as APopover,
Typography,
} from 'ant-design-vue';
import { SettingOutlined } from '@ant-design/icons-vue';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
tableKey: string;
@@ -14,164 +23,49 @@ const props = withDefaults(
);
const open = ref(false);
const root = ref<HTMLElement | null>(null);
const cols = useTableColumns(() => props.tableKey);
const columns = computed(() => cols.columns());
const hiddenCount = computed(() => cols.hiddenCount());
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener('click', onDocClick));
onBeforeUnmount(() => document.removeEventListener('click', onDocClick));
const placement = computed(() =>
props.align === 'end' ? 'bottomLeft' : 'bottomRight',
);
</script>
<template>
<div ref="root" class="tc-pop">
<button
class="tc-tool"
type="button"
:class="{ active: hiddenCount > 0 }"
:title="label"
@click.stop="open = !open"
>
<UiIcon name="sliders" :size="16" />
<span class="tc-label">{{ label }}</span>
<em v-if="hiddenCount" class="tc-badge">{{ toFaDigits(hiddenCount) }} مخفی</em>
</button>
<div v-if="open" class="tc-menu" :class="align" @click.stop>
<p class="tc-head">نمایش ستون‌ها</p>
<p v-if="!columns.length" class="tc-empty">ستونی برای نمایش وجود ندارد.</p>
<label v-for="col in columns" :key="col" class="tc-item">
<input type="checkbox" :checked="cols.isVisible(col)" @change="cols.toggle(col)" />
<span>{{ col }}</span>
</label>
<button v-if="hiddenCount" type="button" class="tc-reset" @click="cols.showAll()">
نمایش همه ستون‌ها
</button>
</div>
</div>
<a-popover v-model:open="open" trigger="click" :placement="placement">
<template #content>
<a-flex
vertical
:gap="8"
style="min-width: 13rem; max-width: calc(100vw - 1.5rem)"
>
<a-typography-text type="secondary" strong>
نمایش ستون‌ها
</a-typography-text>
<a-typography-text v-if="!columns.length" type="secondary">
ستونی برای نمایش وجود ندارد.
</a-typography-text>
<a-checkbox
v-for="col in columns"
:key="col"
:checked="cols.isVisible(col)"
@click.prevent="cols.toggle(col)"
>
{{ col }}
</a-checkbox>
<a-button v-if="hiddenCount" type="link" @click="cols.showAll()">
نمایش همه ستون‌ها
</a-button>
</a-flex>
</template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
<template #icon><SettingOutlined /></template>
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</a-button>
</a-popover>
</template>
<style scoped>
.tc-pop {
position: relative;
}
.tc-tool {
min-height: var(--control-h, 2.4rem);
height: var(--control-h, 2.4rem);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
white-space: nowrap;
}
.tc-tool:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.tc-tool.active {
border-color: #5eead4;
color: #0f766e;
}
.tc-badge {
font-style: normal;
font-size: 0.72rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.tc-menu {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 20;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
padding: 0.45rem;
min-width: 13rem;
max-width: calc(100vw - 1.5rem);
max-height: min(24rem, 70vh);
overflow-y: auto;
display: grid;
gap: 0.1rem;
}
.tc-menu.end {
inset-inline-start: auto;
inset-inline-end: 0;
}
.tc-head {
margin: 0 0 0.25rem;
padding: 0.25rem 0.5rem;
font-size: var(--fs-xs, 0.75rem);
color: var(--ink-muted, #64748b);
font-weight: 700;
}
.tc-empty {
margin: 0;
padding: 0.4rem 0.5rem;
font-size: var(--fs-sm);
color: var(--ink-muted, #64748b);
}
.tc-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.42rem 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: var(--fs-sm);
user-select: none;
}
.tc-item:hover {
background: #f0fdfa;
}
.tc-item input {
width: 1rem;
height: 1rem;
accent-color: #0d9488;
margin: 0;
}
.tc-reset {
margin-top: 0.3rem;
border: 0;
border-top: 1px solid var(--line);
background: none;
padding: 0.5rem;
font: inherit;
font-size: var(--fs-sm);
color: #0f766e;
cursor: pointer;
text-align: start;
}
@media (max-width: 640px) {
.tc-label {
display: none;
}
}
</style>
+24 -326
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { matchesSearch } from '@/lib/search';
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
type PickUser = { id: string; fullName: string };
@@ -21,335 +19,35 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const root = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
let snapshot: string[] = [];
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
const selected = computed(() =>
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
const filtered = computed(() => {
if (!query.value.trim()) return props.users;
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
});
const canRemove = computed(() => props.modelValue.length > props.min);
function sameSet(a: string[], b: string[]) {
return a.length === b.length && a.every((id) => b.includes(id));
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
emit('update:modelValue', ids);
emit('change', ids);
}
function openList() {
if (props.disabled) return;
snapshot = [...props.modelValue];
query.value = '';
open.value = true;
void nextTick(() => searchInput.value?.focus());
}
function closeList() {
if (!open.value) return;
open.value = false;
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
}
function toggle(id: string) {
const has = props.modelValue.includes(id);
if (has && !canRemove.value) return;
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
emit('update:modelValue', next);
}
function remove(id: string) {
if (props.disabled || !canRemove.value) return;
const next = props.modelValue.filter((x) => x !== id);
emit('update:modelValue', next);
if (!open.value) emit('change', next);
}
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeList();
}
onMounted(() => document.addEventListener('mousedown', onDocClick));
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
</script>
<template>
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
<div class="chips">
<span v-for="u in selected" :key="u.id" class="chip">
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
<span class="chip-name">{{ u.fullName }}</span>
<button
v-if="canRemove && !disabled"
type="button"
class="chip-x"
:aria-label="`حذف ${u.fullName}`"
@click.stop="remove(u.id)"
>
<UiIcon name="x" :size="12" />
</button>
</span>
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
<button
type="button"
class="add"
:disabled="disabled"
:aria-expanded="open"
@click.stop="open ? closeList() : openList()"
>
<UiIcon name="user-plus" :size="14" />
{{ addLabel }}
</button>
</div>
<div v-if="open" class="pop" @click.stop>
<label class="search">
<UiIcon name="search" :size="14" />
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
</label>
<ul class="list">
<li v-for="u in filtered" :key="u.id">
<button
type="button"
class="row"
:class="{ on: modelValue.includes(u.id) }"
@click="toggle(u.id)"
>
<span class="check" aria-hidden="true">
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
<span class="row-name">{{ u.fullName }}</span>
</button>
</li>
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
</ul>
<div class="foot">
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
<button type="button" class="done" @click="closeList">تأیید</button>
</div>
</div>
</div>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
</template>
<style scoped>
.ums {
position: relative;
width: 100%;
min-width: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.25rem;
padding-inline-end: 0.5rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #ccfbf1;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
max-width: 100%;
}
.chip-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-x {
width: 20px;
height: 20px;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.chip-x:hover {
background: #fee2e2;
color: #b91c1c;
}
.empty {
color: #94a3b8;
font-size: 0.82rem;
font-weight: 500;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 30px;
padding: 0 0.7rem;
border: 1px dashed #5eead4;
border-radius: 999px;
background: #fff;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.add:hover:not(:disabled) {
background: #f0fdfa;
}
.add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pop {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 30;
width: min(18rem, calc(100vw - 2rem));
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
border: 1px solid #f1f5f9;
display: grid;
overflow: hidden;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.5rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
background: transparent;
color: #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0 0.35rem 0.35rem;
max-height: 15rem;
overflow-y: auto;
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.42rem 0.5rem;
border: 0;
border-radius: 10px;
background: none;
font: inherit;
font-size: 0.86rem;
font-weight: 500;
color: #0f172a;
text-align: start;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.on {
background: #f0fdfa;
font-weight: 700;
}
.check {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 5px;
border: 1.5px solid #cbd5e1;
display: grid;
place-items: center;
color: #fff;
}
.row.on .check {
background: #14b8a6;
border-color: #14b8a6;
}
.row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.none {
padding: 0.75rem;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-top: 1px solid #f1f5f9;
font-size: 0.78rem;
color: #64748b;
}
.done {
border: 0;
border-radius: 8px;
background: #14b8a6;
color: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
padding: 0.35rem 0.9rem;
cursor: pointer;
}
</style>
+17 -93
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const { Text: ATypographyText } = Typography;
type SpeechResult = {
isFinal: boolean;
@@ -186,102 +189,23 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="vtt-wrap" :class="[variant, { live: recording }]">
<button
class="vtt"
type="button"
:class="{ on: recording }"
<div>
<a-button
:type="recording ? 'primary' : 'default'"
:danger="recording"
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<span class="glyph" aria-hidden="true">
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
</span>
<span v-if="recording" class="timer">{{ timerLabel }}</span>
</button>
<p v-if="error && !hideError" class="err">{{ error }}</p>
<template #icon>
<PauseCircleOutlined v-if="recording" />
<AudioOutlined v-else />
</template>
<span v-if="recording">{{ timerLabel }}</span>
</a-button>
<div v-if="error && !hideError">
<a-typography-text type="danger">{{ error }}</a-typography-text>
</div>
</div>
</template>
<style scoped>
.vtt-wrap {
display: grid;
gap: 0.3rem;
justify-items: start;
}
.vtt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #d7e3dc;
background: #f4faf7;
color: #0f766e;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.75rem;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.vtt:hover:not(:disabled) {
background: #ecfdf5;
}
.vtt.on {
border-color: #5eead4;
background: #042f2e;
color: #ecfdf5;
padding-inline: 0.7rem 0.85rem;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
}
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.glyph {
width: 22px;
height: 22px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.timer {
font-variant-numeric: tabular-nums;
min-width: 2.4rem;
}
.toolbar .vtt {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
border-radius: var(--radius);
border-color: transparent;
background: #f8fafc;
justify-content: center;
}
.toolbar.live .vtt {
width: auto;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.7rem;
border-radius: var(--radius);
background: #042f2e;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import UiButton from '@/components/ui/UiButton.vue';
import UiIcon from '@/components/UiIcon.vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -18,15 +18,14 @@ const emit = defineEmits<{
</script>
<template>
<UiButton
variant="dashed"
<a-button
type="dashed"
block
class="add-deal"
:style="{ color: PIPELINE_ACCENT[accent].text }"
:aria-label="label"
@click="emit('add')"
>
<UiIcon name="plus" :size="16" />
<template #icon><PlusOutlined /></template>
{{ label }}
</UiButton>
</a-button>
</template>
+147 -291
View File
@@ -1,6 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Flex as AFlex,
Space as ASpace,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import {
@@ -30,6 +45,9 @@ const emit = defineEmits<{
bulkDelete: [];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null,
);
@@ -47,306 +65,144 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.45 : 1,
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary,
}));
</script>
<template>
<article
class="deal-card"
:class="{
dragging,
'no-amount': !showAmount,
'select-mode': selectMode,
selected,
}"
<a-card
hoverable
:bordered="true"
:body-style="{ padding: '12px 14px', margin: 0 }"
:style="cardStyle"
:data-card-id="deal.id"
:draggable="!selectMode"
role="listitem"
:aria-grabbed="dragging ? 'true' : 'false'"
:style="{
'--badge-bg': colors.soft,
'--badge-fg': colors.text,
}"
@dragstart="!selectMode && emit('dragStart', deal, $event)"
@dragend="emit('dragEnd')"
@dblclick="!selectMode && emit('open', deal.id)"
@click="selectMode && emit('toggleSelect', deal.id)"
>
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selected }"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selected"
@click.stop="emit('toggleSelect', deal.id)"
>
<UiIcon v-if="selected" name="check" :size="14" />
</button>
<span v-else class="badge" aria-hidden="true">{{ deal.initials }}</span>
<div class="titles">
<h3 @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)">
{{ deal.title }}
</h3>
<p v-if="deal.description" class="desc">{{ deal.description }}</p>
</div>
<button
v-if="showTrash"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="16" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menu', deal.id)"
>
<UiIcon name="dots-v" :size="16" />
</button>
<img
class="avatar"
:src="avatarSrc"
alt=""
width="32"
height="32"
:title="assigneeTitle"
/>
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle">
+{{ extraAssignees }}
</span>
<span class="date">
{{ deal.date }}
<UiIcon name="calendar" :size="13" />
</span>
<div v-if="showAmount" class="amount">
<strong>{{ money.number }}</strong>
<span>{{ money.unit }}</span>
</div>
</article>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="flex-start" :style="{ minWidth: 0 }">
<a-button
v-if="selectMode"
type="text"
shape="circle"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selected"
:style="{
width: '28px',
height: '28px',
minWidth: '28px',
color: selected ? token.colorTextLightSolid : token.colorTextTertiary,
border: `2px solid ${selected ? token.colorPrimary : token.colorBorder}`,
background: selected ? token.colorPrimary : token.colorBgContainer,
}"
@click.stop="emit('toggleSelect', deal.id)"
>
<template #icon>
<CheckOutlined v-if="selected" />
</template>
</a-button>
<a-avatar
v-else
:style="{
background: colors.soft,
color: colors.text,
flexShrink: 0,
fontWeight: 700,
fontSize: `${token.fontSizeSM}px`,
}"
>
{{ deal.initials }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35, cursor: 'pointer' }"
:title="deal.title"
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
>
{{ deal.title }}
</a-typography-text>
<a-typography-text
v-if="deal.description"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
}"
:title="deal.description"
>
{{ deal.description }}
</a-typography-text>
</div>
<a-button
v-if="showTrash"
type="text"
danger
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button
v-else
type="text"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menu', deal.id)"
>
<template #icon><MoreOutlined /></template>
</a-button>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{
minWidth: 0,
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<a-space :size="8" align="center" :style="{ minWidth: 0 }">
<a-avatar :src="avatarSrc" :size="24" :title="assigneeTitle" />
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }}
</a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-space>
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</template>
<style scoped>
.deal-card {
--face: 32px;
display: grid;
grid-template-columns: var(--face) minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: 0.5rem;
row-gap: 0.65rem;
align-items: center;
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: var(--radius);
padding: 0.85rem 0.7rem 0.85rem 0.55rem;
min-width: 0;
overflow: hidden;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
cursor: grab;
transition:
box-shadow 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease,
background 0.18s ease;
text-align: right;
direction: rtl;
}
.deal-card.select-mode {
cursor: pointer;
}
.deal-card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.deal-card:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.07);
}
.deal-card:active {
cursor: grabbing;
}
.deal-card.select-mode:active {
cursor: pointer;
}
.deal-card.dragging {
opacity: 0.95;
transform: scale(1.02);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
}
.deal-card.no-amount .date {
grid-column: 2 / -1;
}
.deal-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.badge,
.avatar,
.tick {
width: var(--face);
height: var(--face);
max-width: var(--face);
border-radius: 999px;
justify-self: center;
}
.badge {
display: grid;
place-items: center;
background: var(--badge-bg);
color: var(--badge-fg);
font-size: 0.65rem;
font-weight: 600;
line-height: 1;
}
.tick {
display: grid;
place-items: center;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
padding: 0;
cursor: pointer;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.avatar {
object-fit: cover;
border: 1px solid var(--line);
}
.more-assignees {
width: auto;
min-width: 1.15rem;
height: 1.15rem;
padding: 0 0.22rem;
margin-inline-start: -1.55rem;
margin-top: 1.1rem;
border-radius: 999px;
background: #0f766e;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: grid;
place-items: center;
z-index: 1;
justify-self: end;
align-self: start;
}
.titles {
min-width: 0;
text-align: right;
}
.titles h3 {
margin: 0;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.4;
color: var(--ink);
cursor: pointer;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.desc {
margin: 0.1rem 0 0;
color: var(--muted);
font-size: 0.7rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu {
align-self: start;
justify-self: end;
border: 0;
background: none;
color: var(--muted);
width: auto;
height: auto;
padding: 0;
margin: 0;
line-height: 0;
cursor: pointer;
}
.menu:hover,
.menu:focus-visible {
color: var(--ink);
background: none;
outline: none;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
}
.date {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.25rem;
min-width: 0;
width: 100%;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount {
text-align: left;
display: grid;
justify-items: end;
gap: 0.02rem;
min-width: 0;
}
.amount strong {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
max-width: 100%;
overflow-wrap: anywhere;
}
.amount span {
font-size: 0.65rem;
color: var(--muted);
font-weight: 600;
}
</style>
+109 -180
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
} from 'ant-design-vue';
import PipelineColumn from './PipelineColumn.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY } from '@/lib/pipeline-board';
@@ -39,13 +46,40 @@ const emit = defineEmits<{
const adding = ref(false);
const newName = ref('');
const addInput = ref<HTMLInputElement | null>(null);
const addInput = ref<{ focus: () => void } | null>(null);
const shellStyle = {
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: 0,
minWidth: 0,
position: 'relative' as const,
overflow: 'hidden' as const,
};
const trackStyle = {
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch' as const,
gap: '16px',
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
};
async function startAdd() {
adding.value = true;
newName.value = '';
await nextTick();
addInput.value?.focus();
addInput.value?.focus?.();
}
function cancelAdd() {
@@ -63,193 +97,88 @@ function submitAdd() {
</script>
<template>
<div class="board-shell" dir="rtl">
<div class="board">
<PipelineColumn
v-for="(stage, index) in stages"
:key="stage.id"
:stage="stage"
:dragging-id="draggingId"
:drop-active="dropStageId === stage.id"
:can-manage="canManage"
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@drag-over="(id, e) => emit('dragOver', id, e)"
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<div class="pipeline-board-shell" dir="rtl" :style="shellStyle">
<div class="pipeline-board-track" :style="trackStyle">
<PipelineColumn
v-for="(stage, index) in stages"
:key="stage.id"
:stage="stage"
:dragging-id="draggingId"
:drop-active="dropStageId === stage.id"
:can-manage="canManage"
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@drag-over="(id, e) => emit('dragOver', id, e)"
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<section v-if="canManage" class="add-col">
<form v-if="adding" class="add-form" @submit.prevent="submitAdd">
<input
ref="addInput"
v-model="newName"
maxlength="80"
placeholder="نام ستون جدید…"
aria-label="نام ستون جدید"
@keydown.esc.prevent="cancelAdd"
/>
<div class="add-actions">
<button class="ok" type="submit" :disabled="newName.trim().length < 1">
افزودن
</button>
<button class="cancel" type="button" @click="cancelAdd">انصراف</button>
</div>
</form>
<button v-else type="button" class="add-btn" @click="startAdd">
<UiIcon name="plus" :size="18" />
افزودن ستون
</button>
</section>
<div
v-if="canManage"
:style="{
flex: '0 0 280px',
width: '280px',
alignSelf: 'stretch',
display: 'flex',
alignItems: 'flex-start',
}"
>
<a-card v-if="adding" :style="{ width: '100%' }" :body-style="{ padding: '12px' }">
<a-flex vertical :gap="12">
<a-input
ref="addInput"
v-model:value="newName"
:maxlength="80"
placeholder="نام ستون جدید…"
aria-label="نام ستون جدید"
@pressEnter="submitAdd"
@keydown.esc.prevent="cancelAdd"
/>
<a-space>
<a-button
type="primary"
:disabled="newName.trim().length < 1"
@click="submitAdd"
>
افزودن
</a-button>
<a-button @click="cancelAdd">انصراف</a-button>
</a-space>
</a-flex>
</a-card>
<a-button v-else type="dashed" block :style="{ minHeight: '52px' }" @click="startAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</div>
</div>
</div>
</template>
<style scoped>
.board-shell {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--pad-modal);
min-width: 0;
}
.board {
display: flex;
flex-direction: row;
gap: 16px;
width: 100%;
min-width: 0;
max-width: 100%;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
padding: 0.1rem 0.05rem 0.5rem;
touch-action: pan-x pan-y;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
.pipeline-board-track {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
}
.board::-webkit-scrollbar {
.pipeline-board-track::-webkit-scrollbar {
height: 8px;
}
.board::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 38%, transparent);
.pipeline-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
}
.add-col {
flex: 0 0 17.5rem;
min-width: 17.5rem;
min-height: 420px;
display: flex;
align-items: flex-start;
padding-top: 0.15rem;
}
.add-btn {
width: 100%;
min-height: 3.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1.5px dashed var(--line);
border-radius: var(--radius);
background: #f8fafc;
color: var(--muted);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.add-btn:hover {
border-color: var(--accent);
color: var(--accent-deep, #0f766e);
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.add-form {
width: 100%;
display: grid;
gap: 0.55rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.add-form input {
width: 100%;
min-height: var(--control-h);
padding: 0 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
font: inherit;
}
.add-actions {
display: flex;
gap: 0.4rem;
}
.add-actions .ok,
.add-actions .cancel {
min-height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.add-actions .ok {
border: 0;
background: var(--accent);
color: #fff;
}
.add-actions .ok:disabled {
opacity: 0.5;
cursor: default;
}
.add-actions .cancel {
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
@container pipeline (max-width: 720px) {
.board {
scroll-snap-type: x mandatory;
scroll-padding-inline: 0.25rem;
}
.add-col {
flex: 0 0 min(20rem, 86cqi);
width: min(20rem, 86cqi);
min-width: min(20rem, 86cqi);
min-height: 360px;
scroll-snap-align: start;
}
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>
@@ -1,6 +1,19 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, nextTick, ref } from 'vue';
import {
HolderOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import {
Button as AButton,
Dropdown as ADropdown,
Input as AInput,
Menu as AMenu,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -39,11 +52,63 @@ const emit = defineEmits<{
loadMore: [stageId: string];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const renaming = ref(false);
const draft = ref('');
const titleInput = ref<HTMLInputElement | null>(null);
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
const menuOpen = ref(false);
const menuRoot = ref<HTMLElement | null>(null);
const accent = computed(() => PIPELINE_ACCENT[props.stage.color]);
const columnStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
flex: '0 0 280px',
width: '280px',
alignSelf: 'stretch' as const,
minHeight: 0,
padding: 0,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${
props.dropActive ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
background: props.dropActive
? token.value.colorPrimaryBg
: token.value.colorFillAlter,
boxShadow: props.dropActive
? `0 0 0 2px ${token.value.colorPrimaryBg}`
: undefined,
overflow: 'hidden' as const,
}));
const headStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
minWidth: 0,
}));
const bodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
function onCardDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
@@ -62,7 +127,11 @@ function startRename() {
draft.value = props.stage.title;
renaming.value = true;
menuOpen.value = false;
void nextTick(() => titleInput.value?.select());
void nextTick(() => {
const el = titleInput.value as unknown as HTMLInputElement | null;
el?.focus?.();
el?.select?.();
});
}
function commitRename() {
@@ -78,144 +147,133 @@ function cancelRename() {
draft.value = props.stage.title;
}
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function onColumnDragStart(event: DragEvent) {
event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`);
event.dataTransfer!.effectAllowed = 'move';
emit('columnDragStart', props.stage.id, event);
}
function onDocPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
const root = menuRoot.value;
if (root && target && !root.contains(target)) menuOpen.value = false;
const menuItems = computed(() => [
{ key: 'rename', label: 'تغییر نام' },
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
{ key: 'delete', label: 'حذف ستون', danger: true },
]);
function onMenuClick({ key }: { key: string | number }) {
menuOpen.value = false;
if (key === 'rename') startRename();
else if (key === 'right') emit('move', props.stage.id, -1);
else if (key === 'left') emit('move', props.stage.id, 1);
else if (key === 'delete') emit('delete', props.stage.id);
}
function onMenuOpened() {
document.addEventListener('pointerdown', onDocPointerDown);
}
function onMenuClosed() {
document.removeEventListener('pointerdown', onDocPointerDown);
}
watch(menuOpen, (open) => {
if (open) onMenuOpened();
else onMenuClosed();
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown);
});
</script>
<template>
<section
class="column"
:class="{ drop: dropActive }"
:style="{
'--stage-solid': PIPELINE_ACCENT[stage.color].solid,
'--stage-soft': PIPELINE_ACCENT[stage.color].soft,
'--stage-border': PIPELINE_ACCENT[stage.color].border,
'--stage-text': PIPELINE_ACCENT[stage.color].text,
}"
:style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)"
>
<header class="column-head">
<div :style="headStyle">
<span
v-if="canManage"
class="grip"
draggable="true"
title="کشیدن برای جابه‌جایی"
aria-label="جابه‌جایی ستون"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@dragstart="onColumnDragStart"
@dragend="emit('columnDragEnd')"
>
<UiIcon name="grip" :size="14" />
<HolderOutlined />
</span>
<div class="title-wrap">
<span class="dot" aria-hidden="true" />
<input
v-if="renaming"
ref="titleInput"
v-model="draft"
class="title-input"
maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<h2
v-else
:class="{ editable: canManage }"
:title="canManage ? 'برای تغییر نام کلیک کنید' : undefined"
@click="startRename"
>
{{ stage.title }}
</h2>
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
</div>
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span>
<div v-if="canManage" ref="menuRoot" class="more-wrap">
<button
type="button"
class="icon-btn"
aria-label="گزینه‌های ستون"
:aria-expanded="menuOpen"
@click.stop="toggleMenu"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuOpen" class="col-menu" role="menu">
<button type="button" role="menuitem" @click="startRename">
تغییر نام
</button>
<button
type="button"
role="menuitem"
:disabled="isFirst"
@click="emit('move', stage.id, -1); menuOpen = false"
>
انتقال به راست
</button>
<button
type="button"
role="menuitem"
:disabled="isLast"
@click="emit('move', stage.id, 1); menuOpen = false"
>
انتقال به چپ
</button>
<button
type="button"
role="menuitem"
class="danger"
@click="emit('delete', stage.id); menuOpen = false"
>
حذف ستون
</button>
</div>
</div>
<button
type="button"
class="plus"
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: accent.solid,
display: 'inline-block',
flexShrink: 0,
}"
aria-hidden="true"
/>
<a-input
v-if="renaming"
ref="titleInput"
v-model:value="draft"
:maxlength="80"
aria-label="نام ستون"
:style="{ flex: 1, minWidth: 0 }"
@pressEnter="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<a-typography-text
v-else
strong
ellipsis
:style="{
flex: 1,
minWidth: 0,
margin: 0,
cursor: canManage ? 'text' : 'default',
}"
:title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
@click="startRename"
>
{{ stage.title }}
</a-typography-text>
<a-tag v-if="stage.badge && !renaming" :bordered="false">
{{ stage.badge }}
</a-tag>
<a-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
</a-typography-text>
<a-dropdown
v-if="canManage"
v-model:open="menuOpen"
:trigger="['click']"
placement="bottomLeft"
>
<a-button type="text" aria-label="گزینه‌های ستون" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu :items="menuItems" @click="onMenuClick" />
</template>
</a-dropdown>
<a-button
type="text"
:aria-label="copy.addItemAria"
:style="{ color: accent.solid }"
@click="emit('addDeal', stage.id)"
>
<UiIcon name="plus" :size="16" />
</button>
</header>
<template #icon><PlusOutlined /></template>
</a-button>
</div>
<div class="cards" role="list" :aria-label="stage.title">
<div role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
class="card-slot"
@dragover="onCardDragOver(stage.id, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
@@ -234,256 +292,26 @@ onBeforeUnmount(() => {
@bulk-delete="emit('bulkDelete')"
/>
</div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
<button
<a-typography-text
v-if="!stage.deals.length"
type="secondary"
:style="{ textAlign: 'center', display: 'block', margin: 'auto', padding: '16px' }"
>
{{ copy.empty }}
</a-typography-text>
<a-button
v-if="stage.hasMore"
type="button"
class="load-more"
:disabled="stage.loadingMore"
type="dashed"
block
:loading="stage.loadingMore"
@click="emit('loadMore', stage.id)"
>
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</button>
</a-button>
</div>
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
<div :style="{ padding: '0 12px 12px', flexShrink: 0 }">
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
</div>
</section>
</template>
<style scoped>
.column {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1 1 17.5rem;
min-width: 17.5rem;
min-height: 420px;
padding: 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--line);
background: #fff;
transition: background 0.15s ease, border-color 0.15s ease;
}
.column.drop {
background: color-mix(in srgb, var(--stage-soft) 70%, transparent);
border-color: color-mix(in srgb, var(--stage-border) 80%, transparent);
}
.column-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.1rem 0.1rem 0.15rem;
}
.grip {
display: grid;
place-items: center;
width: 1.25rem;
color: var(--muted);
cursor: grab;
flex: 0 0 auto;
touch-action: none;
}
.grip:active {
cursor: grabbing;
}
.title-wrap {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
min-width: 0;
flex: 1;
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: var(--stage-solid);
flex: 0 0 auto;
}
.column-head h2,
.title-input {
margin: 0;
min-width: 0;
width: 100%;
font-size: 0.98rem;
font-weight: 600;
color: var(--ink);
font: inherit;
font-weight: 600;
}
.column-head h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.column-head h2.editable {
cursor: text;
}
.title-input {
height: 1.7rem;
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.count {
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
.more-wrap {
position: relative;
flex: 0 0 auto;
}
.icon-btn,
.plus {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.icon-btn {
border: 1px solid transparent;
color: var(--muted);
}
.icon-btn:hover,
.icon-btn:focus-visible {
background: var(--stage-soft);
color: var(--stage-solid);
outline: none;
}
.plus {
border: 1.5px solid var(--stage-solid);
color: var(--stage-solid);
}
.plus:hover,
.plus:focus-visible {
background: var(--stage-soft);
outline: none;
}
.col-menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
z-index: 8;
min-width: 10.5rem;
padding: 0.3rem;
display: grid;
gap: 0.1rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.col-menu button {
border: 0;
background: transparent;
padding: 0.45rem 0.7rem;
font: inherit;
font-size: 0.86rem;
color: var(--ink);
text-align: right;
cursor: pointer;
border-radius: var(--radius);
}
.col-menu button:hover:not(:disabled) {
background: #f8fafc;
}
.col-menu button:disabled {
opacity: 0.4;
cursor: default;
}
.col-menu .danger {
color: var(--danger, #dc2626);
}
.cards {
display: flex;
flex-direction: column;
gap: 0.7rem;
flex: 1;
min-width: 0;
min-height: 4rem;
}
.card-slot {
min-width: 0;
}
.empty {
margin: 1rem 0;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
.stage-badge {
flex: 0 0 auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--stage-soft);
color: var(--stage-text);
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
}
.load-more {
min-height: var(--control-h);
border: 1px dashed var(--stage-border);
border-radius: var(--radius);
background: #fff;
color: var(--stage-text);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.load-more:hover:not(:disabled) {
background: var(--stage-soft);
}
.load-more:disabled {
opacity: 0.6;
cursor: default;
}
@container pipeline (max-width: 720px) {
.column {
flex: 0 0 min(20rem, 86cqi);
width: min(20rem, 86cqi);
min-width: min(20rem, 86cqi);
max-width: none;
min-height: 360px;
scroll-snap-align: start;
}
}
</style>
+260 -430
View File
@@ -1,8 +1,31 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Table as ATable,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import UiIcon from '@/components/UiIcon.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import type {
PipelineAccent,
PipelineDeal,
PipelineStage,
PipelineUiCopy,
} from '@/lib/pipeline-board';
import {
DEAL_BOARD_COPY,
formatCurrencyParts,
@@ -26,14 +49,28 @@ const emit = defineEmits<{
bulkDelete: [];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const listRoot = ref<HTMLElement | null>(null);
/** فقط یکی از جدول/کارت در DOM باشد تا منوی انتخاب گم نشود */
const useCards = ref(false);
let resizeObs: ResizeObserver | null = null;
const rows = computed(() =>
type Row = {
key: string;
deal: PipelineDeal;
stageTitle: string;
accent: PipelineAccent;
};
function accentColors(accent: PipelineAccent) {
return PIPELINE_ACCENT[accent];
}
const rows = computed<Row[]>(() =>
props.stages.flatMap((stage) =>
stage.deals.map((deal) => ({
key: deal.id,
deal,
stageTitle: stage.title,
accent: stage.color,
@@ -48,6 +85,33 @@ function money(deal: PipelineDeal) {
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
const columns = computed<TableColumnsType<Row>>(() => {
const cols: TableColumnsType<Row> = [];
if (props.selectMode) {
cols.push({ key: 'select', title: '', width: 56 });
}
cols.push(
{ key: 'title', title: props.copy.listColTitle },
{ key: 'stage', title: 'مرحله' },
);
if (!hideAmount.value) {
cols.push({ key: 'amount', title: 'مبلغ' });
}
cols.push(
{ key: 'date', title: 'تاریخ' },
{ key: 'actions', title: 'عملیات', width: 72, align: 'center' },
);
return cols;
});
function customRow(record: Row) {
return {
onClick: () => {
if (props.selectMode) emit('toggleSelect', record.deal.id);
},
};
}
onMounted(() => {
const el = listRoot.value;
if (!el || typeof ResizeObserver === 'undefined') return;
@@ -66,445 +130,211 @@ onUnmounted(() => {
</script>
<template>
<div ref="listRoot" class="list" dir="rtl">
<p v-if="!rows.length" class="empty">{{ copy.empty }}</p>
<div ref="listRoot" dir="rtl" :style="{ minWidth: 0 }">
<a-empty v-if="!rows.length" :description="copy.empty" />
<div v-if="rows.length && !useCards" class="tc-row">
<TableColumnsMenu :table-key="tableKey" align="end" />
</div>
<div v-if="rows.length && !useCards" class="table-wrap">
<table v-table-cols="tableKey" class="data-table">
<thead>
<tr>
<th v-if="selectMode" class="tick-col"></th>
<th>{{ copy.listColTitle }}</th>
<th>مرحله</th>
<th v-if="!hideAmount">مبلغ</th>
<th>تاریخ</th>
<th class="menu-col">عملیات</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.deal.id"
:data-card-id="row.deal.id"
:class="{ selected: selectedIds.has(row.deal.id) }"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<td v-if="selectMode" class="tick-col" @click.stop>
<button
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selectedIds.has(row.deal.id)"
@click="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
</td>
<td>
<button
type="button"
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
</span>
</button>
</td>
<td>
<span
class="stage"
<template v-else-if="!useCards">
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
<TableColumnsMenu :table-key="tableKey" align="end" />
</a-flex>
<a-table
:columns="columns"
:data-source="rows"
:pagination="false"
row-key="key"
:custom-row="customRow"
:row-class-name="(record: Row) => (selectedIds.has(record.deal.id) ? 'pipeline-row-selected' : '')"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<a-button
type="text"
shape="circle"
:aria-label="selectedIds.has(record.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', record.deal.id)"
>
<template #icon>
<CheckOutlined v-if="selectedIds.has(record.deal.id)" />
</template>
</a-button>
</template>
<template v-else-if="column.key === 'title'">
<a-flex
:gap="10"
align="center"
:style="{ minWidth: 0, cursor: 'pointer' }"
@click.stop="
selectMode
? emit('toggleSelect', record.deal.id)
: emit('openDeal', record.deal.id)
"
>
<a-avatar
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
background: accentColors(record.accent).soft,
color: accentColors(record.accent).text,
flexShrink: 0,
}"
>
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
{{ row.stageTitle }}
</span>
</td>
<td v-if="!hideAmount">
<span class="amount" dir="ltr">
<strong>{{ money(row.deal).number }}</strong>
<small>{{ money(row.deal).unit }}</small>
</span>
</td>
<td>
<span class="date">
<UiIcon name="calendar" :size="14" />
{{ row.deal.date }}
</span>
</td>
<td class="menu-cell" @click.stop>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click="emit('menuDeal', row.deal.id)"
>
<UiIcon name="dots-v" :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
{{ record.deal.initials }}
</a-avatar>
<div :style="{ minWidth: 0 }">
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
{{ record.deal.title }}
</a-typography-text>
<a-typography-text
v-if="record.deal.description"
type="secondary"
ellipsis
:style="{ display: 'block', margin: 0, fontSize: `${token.fontSizeSM}px` }"
>
{{ record.deal.description }}
</a-typography-text>
</div>
</a-flex>
</template>
<template v-else-if="column.key === 'stage'">
<a-tag
:bordered="false"
:style="{
background: accentColors(record.accent).soft,
color: accentColors(record.accent).text,
}"
>
{{ record.stageTitle }}
</a-tag>
</template>
<template v-else-if="column.key === 'amount'">
<span dir="ltr">
<a-typography-text strong>{{ money(record.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
{{ money(record.deal).unit }}
</a-typography-text>
</span>
</template>
<template v-else-if="column.key === 'date'">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="selectMode && selectedIds.has(record.deal.id)"
type="text"
danger
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button
v-else
type="text"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menuDeal', record.deal.id)"
>
<template #icon><MoreOutlined /></template>
</a-button>
</template>
</template>
</a-table>
</template>
<div v-if="rows.length && useCards" class="cards">
<article
<a-flex v-else vertical :gap="12">
<a-card
v-for="row in rows"
:key="row.deal.id"
class="card"
:class="{ selected: selectedIds.has(row.deal.id) }"
hoverable
:data-card-id="row.deal.id"
:body-style="{ padding: '14px 16px' }"
:style="{
background: selectedIds.has(row.deal.id)
? token.colorPrimaryBg
: token.colorBgContainer,
borderColor: selectedIds.has(row.deal.id)
? token.colorPrimary
: token.colorBorderSecondary,
}"
@dblclick="!selectMode && emit('openDeal', row.deal.id)"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<div class="card-top">
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
<button
type="button"
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
<a-flex vertical :gap="12">
<a-flex align="center" :gap="10" :style="{ minWidth: 0 }">
<a-button
v-if="selectMode"
type="text"
shape="circle"
@click.stop="emit('toggleSelect', row.deal.id)"
>
<template #icon>
<CheckOutlined v-if="selectedIds.has(row.deal.id)" />
</template>
</a-button>
<a-avatar
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
background: accentColors(row.accent).soft,
color: accentColors(row.accent).text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
>
{{ row.deal.initials }}
</a-avatar>
<div
:style="{ flex: 1, minWidth: 0, cursor: 'pointer' }"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
{{ row.deal.title }}
</a-typography-text>
<a-typography-text
v-if="row.deal.description"
type="secondary"
ellipsis
:style="{ display: 'block', margin: 0 }"
>
{{ row.deal.description }}
</a-typography-text>
</div>
<a-button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="text"
danger
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button v-else type="text" @click.stop="emit('menuDeal', row.deal.id)">
<template #icon><MoreOutlined /></template>
</a-button>
</a-flex>
<a-tag
:bordered="false"
:style="{
alignSelf: 'flex-start',
background: accentColors(row.accent).soft,
color: accentColors(row.accent).text,
}"
>
{{ row.stageTitle }}
</a-tag>
<a-flex justify="space-between" align="center">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
<span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
{{ money(row.deal).unit }}
</a-typography-text>
</span>
</button>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menuDeal', row.deal.id)"
>
<UiIcon name="dots-v" :size="18" />
</button>
</div>
<span
class="stage"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
{{ row.stageTitle }}
</span>
<div class="card-foot">
<span class="date">
<UiIcon name="calendar" :size="14" />
{{ row.deal.date }}
</span>
<span v-if="!hideAmount" class="amount" dir="ltr">
<strong>{{ money(row.deal).number }}</strong>
<small>{{ money(row.deal).unit }}</small>
</span>
</div>
</article>
</div>
</a-flex>
</a-flex>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.list {
min-width: 0;
}
.empty {
margin: 2rem 0;
text-align: center;
color: var(--muted);
font-weight: 700;
}
.table-wrap {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
.data-table th,
.data-table td {
padding: 0.85rem 1rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.data-table th {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 700;
background: #f8fafc;
}
.data-table tbody tr:last-child td {
border-bottom: 0;
}
.data-table tbody tr:hover td {
background: #f8fafc;
}
.data-table tbody tr.selected td {
background: color-mix(in srgb, var(--accent) 7%, #fff);
}
.tick-col {
width: 2.6rem;
padding-inline: 0.55rem !important;
}
.tick {
width: 1.45rem;
height: 1.45rem;
border-radius: 6px;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
flex: 0 0 auto;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.identity {
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
text-align: right;
font: inherit;
color: inherit;
}
.badge {
flex: 0 0 auto;
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 600;
}
.titles {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.titles strong {
font-size: 0.92rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.titles em {
font-style: normal;
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.stage i {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
display: block;
}
.amount {
display: grid;
justify-items: start;
gap: 0.05rem;
}
.amount strong {
font-weight: 600;
color: var(--ink);
}
.amount small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
}
.date {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted);
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.menu-col,
.menu-cell {
width: 3.2rem;
min-width: 3.2rem;
text-align: center;
}
.menu {
border: 0;
background: transparent;
color: var(--muted);
width: 2rem;
height: 2rem;
padding: 0;
border-radius: var(--radius);
display: inline-grid;
place-items: center;
cursor: pointer;
}
.menu:hover,
.menu:focus-visible {
color: var(--ink);
background: #f1f5f9;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, #fff);
}
.cards {
display: grid;
gap: 0.7rem;
}
.card {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: var(--radius);
padding: 0.95rem 1rem;
display: grid;
gap: 0.7rem;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.card-top {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.card-top .identity {
flex: 1;
}
.card-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
}
</style>
@@ -1,12 +1,23 @@
<script setup lang="ts">
import UiFilterBar from '@/components/ui/UiFilterBar.vue';
import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
import UiSelect from '@/components/ui/UiSelect.vue';
import { computed } from 'vue';
import {
AppstoreOutlined,
FundOutlined,
} from '@ant-design/icons-vue';
import {
Flex as AFlex,
InputNumber as AInputNumber,
InputSearch as AInputSearch,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Typography,
theme,
} from 'ant-design-vue';
export type PipelineViewMode = 'kanban' | 'list';
withDefaults(
const props = withDefaults(
defineProps<{
pipelineName: string;
pipelineOptions: Array<{ id: string; name: string }>;
@@ -27,6 +38,9 @@ const emit = defineEmits<{
create: [];
}>();
const { Title: ATypographyTitle } = Typography;
const { token } = theme.useToken();
const viewMode = defineModel<PipelineViewMode>('viewMode', { required: true });
const search = defineModel<string>('search', { default: '' });
const selectedPipelineId = defineModel<string>('selectedPipelineId', { required: true });
@@ -34,165 +48,112 @@ const amountMin = defineModel<string>('amountMin', { default: '' });
const amountMax = defineModel<string>('amountMax', { default: '' });
const viewOptions = [
{ value: 'kanban' as const, label: 'نمای ستونی', icon: 'grid' as const },
{ value: 'list' as const, label: 'نمای لیست', icon: 'list' as const },
{ value: 'kanban' as const, label: 'ستونی' },
{ value: 'list' as const, label: 'لیست' },
];
function onPick(value: string) {
if (value === '__create__') {
const pipelineSelectOptions = computed(() => {
const list = props.pipelineOptions.map((o) => ({ value: o.id, label: o.name }));
if (props.canCreate) {
list.push({ value: '__create__', label: props.createOptionLabel });
}
return list;
});
function onPick(value: unknown) {
const v = String(value ?? '');
if (v === '__create__') {
emit('create');
return;
}
selectedPipelineId.value = value;
selectedPipelineId.value = v;
}
const amountMinNum = computed({
get: () => (amountMin.value === '' ? undefined : Number(amountMin.value)),
set: (v: number | string | null | undefined) => {
if (v == null || v === '' || Number.isNaN(Number(v))) amountMin.value = '';
else amountMin.value = String(v);
},
});
const amountMaxNum = computed({
get: () => (amountMax.value === '' ? undefined : Number(amountMax.value)),
set: (v: number | string | null | undefined) => {
if (v == null || v === '' || Number.isNaN(Number(v))) amountMax.value = '';
else amountMax.value = String(v);
},
});
</script>
<template>
<header class="toolbar" dir="rtl">
<div class="title-block">
<span class="brand-ico" aria-hidden="true">
<UiIcon :name="icon" :size="18" />
</span>
<h1>{{ pipelineName }}</h1>
</div>
<a-flex vertical :gap="12" dir="rtl">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-space :size="10" align="center">
<span
:style="{
width: `${token.controlHeight}px`,
height: `${token.controlHeight}px`,
borderRadius: `${token.borderRadius}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
aria-hidden="true"
>
<AppstoreOutlined v-if="icon === 'grid'" />
<FundOutlined v-else />
</span>
<a-typography-title :level="4" :style="{ margin: 0 }">
{{ pipelineName }}
</a-typography-title>
</a-space>
<UiSegmented v-model="viewMode" :options="viewOptions">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'grid'" :size="16" />
</template>
</UiSegmented>
<a-space wrap :size="8">
<a-segmented v-model:value="viewMode" :options="viewOptions" />
<a-select
:value="selectedPipelineId"
:options="pipelineSelectOptions"
:style="{ width: '180px' }"
@update:value="onPick"
/>
<slot name="excel" />
</a-space>
</a-flex>
<UiSelect
class="pipeline-pick"
:model-value="selectedPipelineId"
@update:model-value="onPick"
>
<option
v-for="opt in pipelineOptions"
:key="opt.id"
:value="opt.id"
>
{{ opt.name }}
</option>
<option v-if="canCreate" value="__create__">{{ createOptionLabel }}</option>
</UiSelect>
<div class="excel-actions">
<slot name="excel" />
</div>
<UiFilterBar
v-model:search="search"
class="filters"
:show-filters="showAmountFilters"
<!-- یک نوار فیلتر تک‌ردیفه؛ بدون a-row و بدون فاصلهٔ ول -->
<a-flex
wrap="wrap"
align="center"
:gap="8"
:style="{ width: '100%', minWidth: 0 }"
>
<a-input-search
v-model:value="search"
allow-clear
placeholder="جستجو در معاملات…"
:style="{ flex: '1 1 220px', minWidth: '180px', maxWidth: '100%' }"
/>
<template v-if="showAmountFilters">
<label class="field">
<span>حداقل مبلغ</span>
<input v-model="amountMin" type="number" min="0" placeholder="۰" />
</label>
<label class="field">
<span>حداکثر مبلغ</span>
<input v-model="amountMax" type="number" min="0" placeholder="—" />
</label>
<a-input-number
v-model:value="amountMinNum"
:min="0"
placeholder="حداقل مبلغ"
:style="{ width: '140px', flex: '0 0 auto' }"
/>
<a-input-number
v-model:value="amountMaxNum"
:min="0"
placeholder="حداکثر مبلغ"
:style="{ width: '140px', flex: '0 0 auto' }"
/>
</template>
</UiFilterBar>
</header>
<div :style="{ flex: '1 1 240px', minWidth: '200px', maxWidth: '100%' }">
<slot name="filters" />
</div>
</a-flex>
</a-flex>
</template>
<style scoped>
.toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.85rem;
min-width: 0;
width: 100%;
}
.title-block {
display: flex;
align-items: center;
gap: 0.55rem;
flex: 0 0 auto;
min-width: 0;
}
.brand-ico {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
display: grid;
place-items: center;
background: var(--accent-deep);
color: #fff;
flex: 0 0 auto;
}
h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-pick {
flex: 0 1 13rem;
min-width: 8.5rem;
}
.excel-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
flex: 0 0 auto;
}
.excel-actions :deep(.btn-ghost-sm) {
border: 1px solid #e2e8f0;
background: #fff;
color: #0f766e;
border-radius: 10px;
padding: 0.45rem 0.75rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
font: inherit;
}
.filters {
flex: 1 1 12rem;
min-width: 0;
}
.field {
display: grid;
gap: 0.3rem;
}
.field span {
font-size: 0.8rem;
color: var(--muted);
font-weight: 700;
}
@container pipeline (max-width: 720px) {
.toolbar {
flex-wrap: wrap;
}
.title-block {
width: 100%;
}
.pipeline-pick,
.excel-actions,
.filters {
flex: 1 1 100%;
}
}
</style>
@@ -1,12 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiInput, UiSelect } from '@/components/ui';
import {
Button as AButton,
Flex as AFlex,
Input as AInput,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults(
@@ -33,6 +43,10 @@ const typeOptions = computed(() => {
return list;
});
const selectOptions = computed(() =>
typeOptions.value.map((t) => ({ value: t, label: ENTITY_TYPE_LABELS[t] })),
);
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref('');
const results = ref<PickedEntity[]>([]);
@@ -99,126 +113,55 @@ function pick(item: PickedEntity) {
</script>
<template>
<div class="ent-picker">
<div v-if="model" class="picked">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
<strong>{{ model.label }}</strong>
<button type="button" aria-label="حذف اتصال" @click="model = null">
<UiIcon name="x" :size="14" />
</button>
</div>
<a-flex vertical :gap="8" style="width: 100%">
<a-flex v-if="model" justify="space-between" align="center" :gap="8">
<a-space :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[model.type] }}</a-tag>
<a-typography-text strong>{{ model.label }}</a-typography-text>
</a-space>
<a-button type="text" aria-label="حذف اتصال" @click="model = null">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<template v-else-if="typeOptions.length">
<div class="search-row">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
</UiSelect>
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
</div>
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
<li v-if="loading" class="muted">در حال جستجو…</li>
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
<li v-for="r in results" :key="r.id">
<button type="button" @click="pick(r)">{{ r.label }}</button>
</li>
</ul>
<a-flex :gap="8" wrap="wrap">
<a-select
v-if="typeOptions.length > 1"
v-model:value="type"
:options="selectOptions"
style="width: 120px"
/>
<a-input
v-model:value="q"
:placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`"
style="flex: 1; min-width: 160px"
/>
</a-flex>
<a-flex
v-if="results.length || loading || q.trim().length >= 2"
vertical
:gap="4"
>
<a-typography-text v-if="loading" type="secondary">در حال جستجو…</a-typography-text>
<a-typography-text v-else-if="!results.length" type="secondary">
موردی یافت نشد
</a-typography-text>
<a-button
v-for="r in results"
:key="r.id"
type="text"
block
style="text-align: start"
@click="pick(r)"
>
{{ r.label }}
</a-button>
</a-flex>
</template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div>
<a-typography-text v-else type="secondary">
ماژول لید/مشتری برای سازمان فعال نیست.
</a-typography-text>
</a-flex>
</template>
<style scoped>
.ent-picker {
display: grid;
gap: var(--sp-2);
}
.search-row {
display: flex;
gap: var(--sp-2);
}
.type-sel {
width: 110px;
flex-shrink: 0;
}
.search-row > :last-child {
flex: 1 1 auto;
}
.results {
list-style: none;
margin: 0;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
max-height: 220px;
overflow-y: auto;
background: var(--surface);
}
.results li button {
width: 100%;
text-align: right;
border: 0;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ink);
}
.results li button:hover {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.muted {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: var(--fs-sm);
}
.picked {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent-deep);
min-width: 0;
}
.picked strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picked-type {
font-size: var(--fs-xs);
color: var(--muted);
}
.picked button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
}
.picked button:hover {
background: var(--surface);
color: var(--danger);
}
</style>
@@ -8,9 +8,22 @@ import type {
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
import {
Alert as AAlert,
Checkbox as ACheckbox,
Col as ACol,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
@@ -22,6 +35,8 @@ import {
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
open: boolean;
@@ -54,7 +69,23 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
);
const categoryOptions = computed(() => [
{ value: '', label: 'بدون دسته' },
...(ctx.value?.categories ?? []).map((c) => ({ value: c.id, label: c.name })),
]);
const assigneeOptions = computed(() =>
assignees.value.map((u) => ({
value: u.id,
label: `${u.fullName}${u.id === auth.user?.id ? ' (خودم)' : ''}`,
})),
);
async function ensureContext() {
if (ctx.value) return;
@@ -105,7 +136,7 @@ async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
return;
return Promise.reject();
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
@@ -128,6 +159,7 @@ async function submit() {
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
return Promise.reject();
} finally {
saving.value = false;
}
@@ -135,119 +167,90 @@ async function submit() {
</script>
<template>
<UiModal :open="open" title="کار جدید" @close="emit('close')">
<form class="qt-form" @submit.prevent="submit">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
</UiField>
<div class="grid-2">
<UiField label="تاریخ" required>
<a-modal
:open="open"
title="کار جدید"
:confirm-loading="saving"
ok-text="ثبت کار"
cancel-text="انصراف"
destroy-on-close
@ok="submit"
@cancel="emit('close')"
>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان" required>
<a-input
v-model:value="form.title"
placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور"
:maxlength="200"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="2" :maxlength="2000" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" />
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
<UiSelect v-model="form.assigneeId">
<option v-for="u in assignees" :key="u.id" :value="u.id">
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
</option>
</UiSelect>
</UiField>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="اولویت">
<a-select
v-model:value="form.priority"
:options="priorityOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="دسته‌بندی">
<a-select
v-model:value="form.categoryId"
:options="categoryOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="assignees.length > 1" :span="24">
<a-form-item label="مسئول">
<a-select
v-model:value="form.assigneeId"
:options="assigneeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="اتصال به پرونده">
<a-space v-if="preset" :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
<a-typography-text strong>{{ preset.label }}</a-typography-text>
</a-space>
<EntityLinkPicker v-else v-model="form.entity" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-checkbox v-model:checked="form.isMandatory">
کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)
</a-checkbox>
</a-col>
</a-row>
<UiField label="اتصال به پرونده">
<div v-if="preset" class="preset">
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
<strong>{{ preset.label }}</strong>
</div>
<EntityLinkPicker v-else v-model="form.entity" />
</UiField>
<label class="check">
<input v-model="form.isMandatory" type="checkbox" />
<span>کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)</span>
</label>
<p v-if="error" class="error">{{ error }}</p>
</form>
<template #footer>
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.qt-form {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.preset {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.preset span {
font-size: var(--fs-xs);
color: var(--muted);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,18 +1,30 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText, Title: ATypographyTitle } = Typography;
const props = withDefaults(
defineProps<{
entity: RoutineEntityRefDto;
@@ -85,223 +97,90 @@ function onCreated(t: RoutineTaskDto) {
</script>
<template>
<section v-if="enabled" class="related">
<header>
<h3>
<span class="mark"><UiIcon name="check" :size="15" /></span>
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
<UiButton size="sm" variant="outline" @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template>
<a-card v-if="enabled" :bordered="false" :style="{ marginTop: '16px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<CheckOutlined />
<a-typography-title :level="5" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-tag v-if="open.length" :bordered="false">{{ toFaDigits(open.length) }} باز</a-tag>
</a-space>
<a-button @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</UiButton>
</header>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<div v-else-if="!items.length" class="empty">
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 72 72" width="72" height="72">
<rect x="18" y="16" width="36" height="44" rx="8" fill="#f4fbf8" stroke="#b7e4d4" stroke-width="1.6" />
<rect x="26" y="10" width="20" height="12" rx="4" fill="#e7f6f1" stroke="#7dcebb" stroke-width="1.6" />
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<p class="muted">{{ emptyTitle }}</p>
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p>
<button class="empty-cta" type="button" @click="quickOpen = true">
ایجاد Task
<UiIcon name="plus" :size="14" />
</button>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<a-alert
v-if="actions.error.value"
type="error"
show-icon
:message="actions.error.value"
:style="{ marginTop: '12px' }"
/>
<div v-else class="rows">
<RoutineTaskRow
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</button>
<template v-if="showClosed">
<a-spin :spinning="loading && !items.length">
<a-empty
v-if="!loading && !items.length"
:description="emptyTitle"
:style="{ margin: '16px 0' }"
>
<template v-if="emptyHint" #description>
<div>
<div>{{ emptyTitle }}</div>
<a-typography-text type="secondary">{{ emptyHint }}</a-typography-text>
</div>
</template>
<a-button type="primary" @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</a-button>
</a-empty>
<a-flex v-else vertical :gap="8" :style="{ marginTop: '12px' }">
<RoutineTaskRow
v-for="t in closed"
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</div>
<a-button
v-if="closed.length"
type="link"
@click="showClosed = !showClosed"
>
{{ showClosed ? 'پنهان کردن' : 'نمایش' }}
{{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</a-button>
<template v-if="showClosed">
<RoutineTaskRow
v-for="t in closed"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</a-flex>
</a-spin>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
</section>
</a-card>
</template>
<style scoped>
.related {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
h3 em {
font-style: normal;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
padding: 1px 8px;
border-radius: 99px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.toggle-closed {
justify-self: start;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
cursor: pointer;
padding: 4px 0;
}
.error {
margin: 0;
}
.empty {
display: grid;
gap: 0.35rem;
}
.empty-art,
.hint,
.empty-cta {
display: none;
}
@media (max-width: 900px) {
.fancy {
gap: 0.85rem;
padding: 0.95rem 0.85rem 1rem;
border: 1px solid #e7eef2;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.fancy .mark {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.fancy header :deep(.ui-btn),
.fancy header :deep(button) {
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
box-shadow: none;
height: auto;
min-height: 34px;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 700;
flex-direction: row-reverse;
gap: 0.2rem;
}
.fancy .empty {
justify-items: center;
text-align: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem 0.2rem;
}
.fancy .empty-art {
display: grid;
place-items: center;
width: 92px;
height: 92px;
margin-bottom: 0.2rem;
border-radius: 24px;
background: linear-gradient(180deg, #f7fcfa, #eef8f4);
}
.fancy .hint {
display: block;
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
}
.fancy .empty-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
}
</style>
@@ -1,9 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Alert as AAlert,
Col as ACol,
FormItem as AFormItem,
Modal as AModal,
Row as ARow,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal);
@@ -57,81 +67,63 @@ const submitLabel = computed(() => {
return 'ذخیره';
}
});
const okDanger = computed(() => modal.value.kind === 'cancel');
</script>
<template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
<form class="rt-modal" @submit.prevent="actions.submit()">
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
<a-modal
:open="!!modal.kind"
:title="title"
:confirm-loading="modal.saving"
:ok-text="submitLabel"
:ok-button-props="{ danger: okDanger }"
cancel-text="انصراف"
destroy-on-close
@ok="actions.submit()"
@cancel="actions.close()"
>
<a-typography-text v-if="modal.task" strong>
{{ modal.task.title }}
</a-typography-text>
<template v-if="modal.kind === 'reschedule'">
<div class="when">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</UiField>
</div>
</template>
<a-row v-if="modal.kind === 'reschedule'" :gutter="[16, 0]" :style="{ marginTop: '12px' }">
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</a-form-item>
</a-col>
</a-row>
<UiField v-else :label="noteLabel" :required="noteRequired">
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
</UiField>
<a-form-item
v-else
:label="noteLabel"
:required="noteRequired"
:style="{ marginTop: '12px' }"
>
<a-textarea
v-model:value="modal.note"
:rows="4"
:placeholder="placeholder"
:maxlength="2000"
/>
</a-form-item>
<p v-if="modal.kind === 'cancel'" class="hint">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<a-typography-text v-if="modal.kind === 'cancel'" type="secondary">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</a-typography-text>
<template #footer>
<UiButton
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
:loading="modal.saving"
@click="actions.submit()"
>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="modal.error"
type="error"
show-icon
:message="modal.error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.rt-modal {
display: grid;
gap: var(--sp-4);
}
.task-name {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.when {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.when {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,17 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Flex as AFlex,
Menu as AMenu,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
FlagOutlined,
MoreOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
@@ -20,6 +36,9 @@ import {
isOpenStatus,
} from '@/lib/routines';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
@@ -45,396 +64,135 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
done.value
? `برگرداندن «${props.task.title}» به حالت انجام‌نشده`
: `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
function onMenuClick({ key }: { key: string | number }) {
const k = String(key);
if (k === 'open') {
emit('open', props.task);
return;
}
act(k as RoutineTaskRowAction);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
<a-flex
align="flex-start"
:gap="12"
:style="{
width: '100%',
padding: '12px 16px',
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: '8px',
cursor: 'pointer',
opacity: closed ? 0.62 : 1,
}"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
<a-checkbox
:checked="done"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
@click.stop
@update:checked="emit('toggle', task)"
/>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<a-flex vertical :gap="4" style="flex: 1; min-width: 0">
<a-space :size="6">
<a-typography-title
:level="5"
:style="{
margin: 0,
textDecoration: done ? 'line-through' : undefined,
}"
>
{{ task.title }}
</a-typography-title>
<FlagOutlined v-if="task.isMandatory" />
</a-space>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
<a-typography-paragraph
v-if="task.description"
type="secondary"
:ellipsis="{ rows: 1 }"
:style="{ marginBottom: 0 }"
>
{{ task.description }}
</a-typography-paragraph>
<a-space wrap :size="[8, 4]">
<a-typography-text v-if="showDate" type="secondary">
<CalendarOutlined /> {{ formatDayNumeric(task.scheduledFor) }}
</a-typography-text>
<a-typography-text v-if="showTime" type="secondary">
<ClockCircleOutlined /> {{ formatTime(task.dueAt) }}
</a-typography-text>
<a-typography-text
v-if="showDelay && task.status === 'overdue'"
type="danger"
>
<WarningOutlined /> {{ formatDelay(task.dueAt) }} تأخیر
</a-typography-text>
<a-typography-text v-if="done && task.completedAt" type="success">
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
</a-tag>
<a-tag :bordered="false">{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</a-tag>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
<a-tag color="processing" :bordered="false">
{{ ENTITY_TYPE_LABELS[ent.type] }} · {{ ent.label }}
</a-tag>
</RouterLink>
</div>
</div>
</a-space>
</a-flex>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<a-space :size="8" @click.stop>
<a-space v-if="showAssignee && task.assignee" :size="6">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
<a-typography-text type="secondary">{{ task.assignee.fullName }}</a-typography-text>
</a-space>
<a-dropdown>
<a-button type="text" aria-label="عملیات بیشتر">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="open">جزئیات</a-menu-item>
<a-menu-item v-if="task.status === 'todo'" key="start">شروع کار</a-menu-item>
<a-menu-item v-if="open || done" key="complete-note">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</a-menu-item>
<a-menu-item v-if="open" key="reschedule">تغییر زمان</a-menu-item>
<a-menu-item v-if="task.status === 'overdue'" key="move-today">
انتقال به امروز
</a-menu-item>
<a-menu-item v-if="open && task.rules.allowSkip" key="skip">رد کردن</a-menu-item>
<a-menu-item v-if="open" key="cancel" danger>لغو کار</a-menu-item>
</a-menu>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</a-dropdown>
</a-space>
</a-flex>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>