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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user