Merge design_ui_v2 into design_ui so the new design system can be previewed without dropping later product work.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,7 +90,7 @@ form {
|
||||
gap: 0.45rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ input {
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
@@ -126,7 +126,7 @@ button:disabled {
|
||||
|
||||
.answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { UiField, UiInput, UiSelect } from '@/components/ui';
|
||||
import type {
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
@@ -58,27 +59,28 @@ onMounted(load);
|
||||
<template>
|
||||
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
|
||||
<div v-else-if="fields.length" class="custom-block">
|
||||
<h3>فیلدهای سفارشی</h3>
|
||||
<div class="grid">
|
||||
<div v-for="field in fields" :key="field.id" class="field">
|
||||
<label :for="`cf-${field.key}`">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="req">*</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
<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}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@change="setValue(field.key, ($event.target as HTMLSelectElement).value)"
|
||||
: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>
|
||||
</select>
|
||||
</UiSelect>
|
||||
|
||||
<label v-else-if="field.fieldType === 'boolean'" class="check">
|
||||
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
|
||||
<input
|
||||
:id="`cf-${field.key}`"
|
||||
type="checkbox"
|
||||
@@ -87,20 +89,18 @@ onMounted(load);
|
||||
setValue(field.key, ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
فعال
|
||||
<span>فعال</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
<UiInput
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
type="number"
|
||||
:value="String(displayValue(field.key))"
|
||||
@input="
|
||||
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
|
||||
@update:model-value="
|
||||
setValue(
|
||||
field.key,
|
||||
($event.target as HTMLInputElement).value === ''
|
||||
? null
|
||||
: Number(($event.target as HTMLInputElement).value),
|
||||
$event === '' || $event == null ? null : Number($event),
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -112,66 +112,55 @@ onMounted(load);
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
|
||||
<input
|
||||
<UiInput
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
type="text"
|
||||
:value="String(displayValue(field.key))"
|
||||
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</UiField>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-block {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
.custom-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.req {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
.cf-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cf-check input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -289,7 +289,7 @@ onMounted(load);
|
||||
.composer input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -316,7 +316,7 @@ onMounted(load);
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fbfcfb;
|
||||
}
|
||||
|
||||
@@ -675,7 +675,7 @@ onBeforeUnmount(() => {
|
||||
justify-content: flex-start;
|
||||
gap: 0.8rem;
|
||||
min-height: 52px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.85rem 0.45rem 0.45rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fffd 100%);
|
||||
}
|
||||
@@ -698,7 +698,7 @@ onBeforeUnmount(() => {
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
transform-origin: center bottom;
|
||||
border-radius: 99px;
|
||||
border-radius: var(--radius);
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
@@ -726,7 +726,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.clock-text {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 1.05rem;
|
||||
@@ -768,7 +768,7 @@ onBeforeUnmount(() => {
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
||||
padding: 0.75rem 0.8rem 0.7rem;
|
||||
font-family: IRANSans, Tahoma, sans-serif;
|
||||
@@ -797,7 +797,7 @@ onBeforeUnmount(() => {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: #f1f5f9;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
@@ -826,7 +826,7 @@ onBeforeUnmount(() => {
|
||||
.day {
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
@@ -881,12 +881,12 @@ onBeforeUnmount(() => {
|
||||
min-width: 5.1rem;
|
||||
height: 4.1rem;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 2.35rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
@@ -900,7 +900,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.md-colon {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
padding-bottom: 0.15rem;
|
||||
@@ -1005,7 +1005,7 @@ onBeforeUnmount(() => {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -1033,7 +1033,7 @@ onBeforeUnmount(() => {
|
||||
height: 220px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
padding: 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
@@ -1044,7 +1044,7 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
@@ -1087,9 +1087,9 @@ onBeforeUnmount(() => {
|
||||
margin-inline-start: auto;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ h2 {
|
||||
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
@@ -221,7 +221,7 @@ textarea {
|
||||
|
||||
.items li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
@@ -281,7 +281,7 @@ header {
|
||||
input[type='text'],
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
|
||||
@@ -5,8 +5,9 @@ import type { RouteLocationRaw } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const FAB_SIZE = 36;
|
||||
const FAB_MARGIN = 20;
|
||||
const POS_KEY = 'mento.quickCreateFab';
|
||||
const FAB_MARGIN_BOTTOM = 24;
|
||||
const FAB_MARGIN_LEFT = 24;
|
||||
const POS_KEY = 'mento.quickCreateFab_v2';
|
||||
|
||||
type FabPos = { left: number; top: number };
|
||||
|
||||
@@ -67,7 +68,10 @@ const router = useRouter();
|
||||
const hidden = computed(() => route.name === 'person-detail');
|
||||
const isQuickCreateOpen = ref(false);
|
||||
const ready = ref(false);
|
||||
const fabPos = ref<FabPos>({ left: FAB_MARGIN, top: FAB_MARGIN });
|
||||
const fabPos = ref<FabPos>({
|
||||
left: FAB_MARGIN_LEFT,
|
||||
top: typeof window !== 'undefined' ? window.innerHeight - FAB_MARGIN_BOTTOM - FAB_SIZE : 600,
|
||||
});
|
||||
const viewport = ref({ w: 1280, h: 800 });
|
||||
const dragging = ref(false);
|
||||
let dragMoved = false;
|
||||
@@ -75,10 +79,10 @@ let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
|
||||
function defaultPos(): FabPos {
|
||||
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
|
||||
const extra = viewport.value.w <= 900 ? 64 : 0;
|
||||
return {
|
||||
left: FAB_MARGIN,
|
||||
top: Math.max(FAB_MARGIN, viewport.value.h - bottomGap - FAB_SIZE),
|
||||
left: FAB_MARGIN_LEFT,
|
||||
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,7 +175,7 @@ const fabStyle = computed(() => ({
|
||||
const panelStyle = computed(() => {
|
||||
const gap = 12;
|
||||
const edge = 16;
|
||||
const width = Math.min(390, viewport.value.w - 32);
|
||||
const width = Math.min(300, viewport.value.w - 32);
|
||||
let left = fabPos.value.left;
|
||||
left = Math.min(left, viewport.value.w - width - edge);
|
||||
left = Math.max(edge, left);
|
||||
@@ -278,7 +282,7 @@ onBeforeUnmount(() => {
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="item-ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="26" />
|
||||
<UiIcon :name="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
@@ -303,7 +307,7 @@ onBeforeUnmount(() => {
|
||||
@pointercancel="onFabPointerUp"
|
||||
>
|
||||
<span class="fab-face" aria-hidden="true">
|
||||
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="28" />
|
||||
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,25 +332,26 @@ onBeforeUnmount(() => {
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 92;
|
||||
width: 390px;
|
||||
max-height: min(540px, calc(100dvh - 80px));
|
||||
width: 300px;
|
||||
max-height: min(480px, calc(100dvh - 80px));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 16px 16px 12px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 12px 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0 12px;
|
||||
margin: 0 0 10px;
|
||||
text-align: right;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.items {
|
||||
@@ -354,31 +359,37 @@ onBeforeUnmount(() => {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding-left: 2px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #eef1f2;
|
||||
border-radius: 16px;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--line-light);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
|
||||
box-shadow: var(--shadow-xs);
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.item-ico {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
@@ -390,7 +401,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.item-ico.company {
|
||||
background: #22d3ee;
|
||||
background: #0891b2;
|
||||
}
|
||||
|
||||
.item-ico.deal {
|
||||
@@ -398,7 +409,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.item-ico.activity {
|
||||
background: #f97316;
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
.item-ico.note {
|
||||
@@ -407,33 +418,34 @@ onBeforeUnmount(() => {
|
||||
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.copy strong {
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--ink);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.copy small {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
z-index: 93;
|
||||
box-sizing: border-box;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
|
||||
color: #fff;
|
||||
@@ -442,7 +454,7 @@ onBeforeUnmount(() => {
|
||||
line-height: 0;
|
||||
direction: ltr;
|
||||
box-shadow:
|
||||
0 8px 22px rgba(13, 148, 136, 0.4),
|
||||
0 4px 14px rgba(13, 148, 136, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
@@ -463,29 +475,28 @@ onBeforeUnmount(() => {
|
||||
|
||||
.fab :deep(.ico) {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
max-width: 28px;
|
||||
max-height: 28px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
stroke-width: 1.7;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
filter: brightness(1.06);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(13, 148, 136, 0.48),
|
||||
0 6px 18px rgba(13, 148, 136, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.fab.open {
|
||||
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.fab.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 12px 28px rgba(13, 148, 136, 0.5);
|
||||
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,13 @@ defineProps<{
|
||||
| 'logout'
|
||||
| 'pulse'
|
||||
| 'sliders'
|
||||
| 'camera';
|
||||
| 'camera'
|
||||
| 'arrow-right'
|
||||
| 'arrow-left'
|
||||
| 'chevron-right'
|
||||
| 'chevron-left'
|
||||
| 'chevrons-right'
|
||||
| 'chevrons-left';
|
||||
size?: number;
|
||||
}>();
|
||||
</script>
|
||||
@@ -218,9 +224,24 @@ defineProps<{
|
||||
<template v-else-if="name === 'plus'">
|
||||
<path d="M12 6v12M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron'">
|
||||
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron-left'">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-right'">
|
||||
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-left'">
|
||||
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-right'">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-left'">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'star'">
|
||||
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
|
||||
</template>
|
||||
|
||||
@@ -52,7 +52,7 @@ img {
|
||||
em {
|
||||
font-style: normal;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ onBeforeUnmount(() => {
|
||||
.meter i {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 99px;
|
||||
border-radius: var(--radius);
|
||||
background: #5eead4;
|
||||
box-shadow: 0 0 8px rgba(94, 234, 212, 0.45);
|
||||
transition: height 70ms linear;
|
||||
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border-color: transparent;
|
||||
background: #f8fafc;
|
||||
justify-content: center;
|
||||
|
||||
@@ -296,8 +296,8 @@ onBeforeUnmount(() => {
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
padding: 0.9rem 0.95rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ onBeforeUnmount(() => {
|
||||
.ctx-copy h3 {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.editor {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
min-height: 11.5rem;
|
||||
display: flex;
|
||||
@@ -448,13 +448,13 @@ onBeforeUnmount(() => {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
padding: 0.42rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -510,7 +510,7 @@ onBeforeUnmount(() => {
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -536,7 +536,7 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.72rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
font-size: 0.8rem;
|
||||
@@ -559,7 +559,7 @@ onBeforeUnmount(() => {
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
height: min(92dvh, 860px);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,8 +274,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.titles h2 {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ onBeforeUnmount(() => {
|
||||
min-height: 72px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
@@ -340,7 +340,7 @@ onBeforeUnmount(() => {
|
||||
.label,
|
||||
.copy strong {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ onBeforeUnmount(() => {
|
||||
.ico-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #e6f7f4;
|
||||
color: #0d9488;
|
||||
display: grid;
|
||||
@@ -375,13 +375,13 @@ onBeforeUnmount(() => {
|
||||
.when {
|
||||
color: #0d9488;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-box {
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -419,7 +419,7 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
@@ -452,7 +452,7 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
height: min(92dvh, 860px);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ withDefaults(
|
||||
.brand-copy strong {
|
||||
font-size: 1.2rem;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
@@ -117,7 +117,7 @@ withDefaults(
|
||||
aspect-ratio: 1.35;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -150,7 +150,7 @@ withDefaults(
|
||||
|
||||
.chart-line {
|
||||
height: 54px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
|
||||
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
|
||||
@@ -184,7 +184,7 @@ withDefaults(
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ function nodeClass(step: AutomationStep) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 22rem;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
background-color: #f3f6f5;
|
||||
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
|
||||
@@ -254,7 +254,7 @@ function nodeClass(step: AutomationStep) {
|
||||
gap: 0.7rem;
|
||||
text-align: start;
|
||||
padding: 0.72rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #d5ddd8;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
|
||||
@@ -273,7 +273,7 @@ function nodeClass(step: AutomationStep) {
|
||||
}
|
||||
|
||||
.node.action:not(.condition) {
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
border-inline-start-width: 5px;
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ function nodeClass(step: AutomationStep) {
|
||||
.icon {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -350,7 +350,7 @@ function nodeClass(step: AutomationStep) {
|
||||
}
|
||||
|
||||
.node.condition .icon {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
color: #b45309;
|
||||
background: #ffedd5;
|
||||
}
|
||||
@@ -369,7 +369,7 @@ function nodeClass(step: AutomationStep) {
|
||||
|
||||
strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ em {
|
||||
margin-top: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #d5ddd8;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
|
||||
@@ -278,14 +278,14 @@ watch(
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@@ -312,7 +312,7 @@ input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
|
||||
@@ -64,7 +64,7 @@ withDefaults(
|
||||
width: max-content;
|
||||
max-width: 118px;
|
||||
padding: 0.48rem 0.65rem 0.55rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 0.68rem;
|
||||
|
||||
@@ -56,7 +56,7 @@ withDefaults(
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text small {
|
||||
|
||||
@@ -93,7 +93,7 @@ function pick(label: string) {
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1rem 0.95rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
@@ -144,7 +144,7 @@ h2 {
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
color: #334155;
|
||||
@@ -168,7 +168,7 @@ h2 {
|
||||
.chip-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
@@ -185,7 +185,7 @@ h2 {
|
||||
.ai-input input {
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.58rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
font-size: 0.8rem;
|
||||
@@ -194,7 +194,7 @@ h2 {
|
||||
.ai-input button {
|
||||
width: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
@@ -208,7 +208,7 @@ h2 {
|
||||
|
||||
.ai-answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ function alertIcon(tone: string) {
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
padding: 0.8rem 1.5rem 0.9rem;
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
@@ -151,7 +151,7 @@ function alertIcon(tone: string) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background-color: #134e4a;
|
||||
background-image:
|
||||
@@ -251,7 +251,7 @@ function alertIcon(tone: string) {
|
||||
.greet-text h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function alertIcon(tone: string) {
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
@@ -311,7 +311,7 @@ function alertIcon(tone: string) {
|
||||
.chip-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -369,7 +369,7 @@ function alertIcon(tone: string) {
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.14rem 0.38rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ const iconTones: Record<string, string> = {
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.1rem 1.15rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
@@ -67,7 +67,7 @@ const iconTones: Record<string, string> = {
|
||||
.kpi-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -101,7 +101,7 @@ const cols = computed<Col[]>(() => {
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
@@ -151,7 +151,7 @@ a {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
color: inherit;
|
||||
@@ -185,12 +185,12 @@ a {
|
||||
.deal-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ const rows = computed<Row[]>(() => {
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
@@ -151,7 +151,7 @@ a {
|
||||
.act-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
|
||||
@@ -47,7 +47,7 @@ function trophy(rank: number) {
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
@@ -89,12 +89,12 @@ a {
|
||||
.cust-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -775,7 +775,7 @@ onMounted(async () => {
|
||||
gap: 0.28rem;
|
||||
min-width: 0;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -789,7 +789,7 @@ onMounted(async () => {
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.balance-box em {
|
||||
@@ -843,7 +843,7 @@ onMounted(async () => {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -918,7 +918,7 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -942,21 +942,21 @@ onMounted(async () => {
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.8rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
@@ -119,7 +119,7 @@ withDefaults(
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #1e2937;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ function onSelect(key: string) {
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #334155;
|
||||
@@ -175,7 +175,7 @@ function onSelect(key: string) {
|
||||
list-style: none;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -193,7 +193,7 @@ function onSelect(key: string) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
|
||||
@@ -146,7 +146,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-fg);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
.titles h3 {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
@@ -230,7 +230,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
|
||||
.amount strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -295,10 +295,10 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.column-head h2 {
|
||||
@@ -315,7 +315,7 @@ onBeforeUnmount(() => {
|
||||
height: 1.7rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ onBeforeUnmount(() => {
|
||||
color: var(--ink);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.col-menu button:hover:not(:disabled) {
|
||||
|
||||
@@ -233,7 +233,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.titles {
|
||||
@@ -244,7 +244,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
|
||||
.titles strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -285,7 +285,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
}
|
||||
|
||||
.amount strong {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.2rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
|
||||
@@ -133,7 +133,7 @@ function onPick(value: string) {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'neutral' | 'accent' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
size?: 'sm' | 'md';
|
||||
dot?: boolean;
|
||||
}>(),
|
||||
{
|
||||
variant: 'neutral',
|
||||
size: 'md',
|
||||
dot: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-badge" :class="[variant, size]">
|
||||
<span v-if="dot" class="badge-dot" aria-hidden="true" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ui-badge.sm {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-badge.neutral {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-badge.accent,
|
||||
.ui-badge.primary {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info-deep);
|
||||
border-color: color-mix(in srgb, var(--info) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'dashed' | 'icon';
|
||||
type?: 'button' | 'submit';
|
||||
variant?: 'primary' | 'ghost' | 'dashed' | 'icon' | 'danger' | 'outline' | 'link' | 'secondary' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
block?: boolean;
|
||||
}>(),
|
||||
{ variant: 'primary', type: 'button' },
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
block: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -14,9 +23,12 @@ withDefaults(
|
||||
<button
|
||||
:type="type"
|
||||
class="ui-btn"
|
||||
:class="[variant, { block }]"
|
||||
:disabled="disabled"
|
||||
:class="[variant, size, { block, loading }]"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<span v-if="loading" class="spinner" aria-hidden="true"></span>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -26,18 +38,52 @@ withDefaults(
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
padding: 0 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-contrast);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.ui-btn.sm {
|
||||
min-height: var(--control-h-sm);
|
||||
height: var(--control-h-sm);
|
||||
padding: 0 0.65rem;
|
||||
font-size: var(--fs-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ui-btn.md {
|
||||
min-height: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
padding: 0 0.85rem;
|
||||
font-size: var(--fs-sm);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ui-btn.lg {
|
||||
min-height: var(--control-h-lg);
|
||||
height: var(--control-h-lg);
|
||||
padding: 0 1.15rem;
|
||||
font-size: var(--fs-base);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.ui-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.ui-btn:disabled {
|
||||
@@ -49,28 +95,125 @@ withDefaults(
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-btn.ghost {
|
||||
background: #fff;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-btn.outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-btn.outline:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn.secondary {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-btn.secondary:hover:not(:disabled) {
|
||||
background: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.ui-btn.danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-btn.danger:hover:not(:disabled) {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.ui-btn.success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.ui-btn.success:hover:not(:disabled) {
|
||||
background: var(--success-deep);
|
||||
}
|
||||
|
||||
.ui-btn.dashed {
|
||||
background: #fff;
|
||||
border: 1.5px dashed currentColor;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
border: 1.5px dashed var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-btn.dashed:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn.link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--accent);
|
||||
padding: 0 var(--sp-1);
|
||||
min-height: auto;
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.ui-btn.link:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ui-btn.icon {
|
||||
width: var(--control-h);
|
||||
width: var(--control-h-md);
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: var(--accent-deep);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-btn.icon.sm {
|
||||
width: var(--control-h-sm);
|
||||
min-height: var(--control-h-sm);
|
||||
}
|
||||
|
||||
.ui-btn.icon.lg {
|
||||
width: var(--control-h-lg);
|
||||
min-height: var(--control-h-lg);
|
||||
}
|
||||
|
||||
.ui-btn.icon:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ui-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ui-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
shadow?: 'none' | 'xs' | 'sm' | 'md';
|
||||
interactive?: boolean;
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
padding: 'md',
|
||||
shadow: 'xs',
|
||||
interactive: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-card">
|
||||
<slot />
|
||||
<div
|
||||
class="ui-card"
|
||||
:class="[
|
||||
`pad-${padding}`,
|
||||
`shadow-${shadow}`,
|
||||
{ interactive }
|
||||
]"
|
||||
>
|
||||
<header v-if="title || $slots.header" class="card-head">
|
||||
<slot name="header">
|
||||
<h3 v-if="title" class="card-title">{{ title }}</h3>
|
||||
</slot>
|
||||
<div v-if="$slots.actions" class="card-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="card-foot">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-card {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Padding variants */
|
||||
.ui-card.pad-none {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-card.pad-sm {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.ui-card.pad-md {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
|
||||
.ui-card.pad-lg {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
/* Shadow variants */
|
||||
.ui-card.shadow-none {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ui-card.shadow-xs {
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.ui-card.shadow-sm {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-card.shadow-md {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.ui-card.interactive:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
padding-top: var(--sp-3);
|
||||
margin-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, PieChart } from 'echarts/charts';
|
||||
import { LineChart, PieChart, BarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
TitleComponent,
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ComposeOption } from 'echarts/core';
|
||||
import type { LineSeriesOption, PieSeriesOption } from 'echarts/charts';
|
||||
import type { LineSeriesOption, PieSeriesOption, BarSeriesOption } from 'echarts/charts';
|
||||
import type {
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TooltipComponentOption,
|
||||
TitleComponentOption,
|
||||
} from 'echarts/components';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
PieChart,
|
||||
BarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
]);
|
||||
|
||||
export type UiChartOption = ComposeOption<
|
||||
| LineSeriesOption
|
||||
| PieSeriesOption
|
||||
| BarSeriesOption
|
||||
| GridComponentOption
|
||||
| TooltipComponentOption
|
||||
| LegendComponentOption
|
||||
| TitleComponentOption
|
||||
>;
|
||||
|
||||
/**
|
||||
* Shared palette matching Montact CRM design tokens
|
||||
*/
|
||||
export const CHART_PALETTE = [
|
||||
'#14b8a6', // Teal (Accent)
|
||||
'#3b82f6', // Blue
|
||||
'#f59e0b', // Amber
|
||||
'#10b981', // Emerald
|
||||
'#8b5cf6', // Violet
|
||||
'#f43f5e', // Rose
|
||||
'#06b6d4', // Cyan
|
||||
'#eab308', // Yellow
|
||||
];
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VChart from 'vue-echarts';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
option: UiChartOption;
|
||||
height?: string;
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ height: '220px' },
|
||||
{ height: '260px', loading: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VChart class="ui-chart" :option="option" :style="{ height }" autoresize />
|
||||
<div class="ui-chart-container" :style="{ height }">
|
||||
<div v-if="loading" class="chart-loading">
|
||||
<div class="chart-spinner"></div>
|
||||
</div>
|
||||
<VChart v-else class="ui-chart" :option="option" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-chart {
|
||||
.ui-chart-container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chart-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: chart-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chart-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: IconName;
|
||||
}>(),
|
||||
{
|
||||
title: 'موردی یافت نشد',
|
||||
icon: 'search',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-empty-state">
|
||||
<div class="icon-wrap">
|
||||
<slot name="icon">
|
||||
<UiIcon :name="icon" :size="32" />
|
||||
</slot>
|
||||
</div>
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
<p v-if="description" class="description">{{ description }}</p>
|
||||
<slot />
|
||||
<div v-if="$slots.action" class="action-wrap">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--sp-10) var(--sp-6);
|
||||
gap: var(--sp-3);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
max-width: 360px;
|
||||
line-height: var(--lh-relaxed);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.action-wrap {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
forId?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string | boolean;
|
||||
horizontal?: boolean;
|
||||
}>(),
|
||||
{
|
||||
required: false,
|
||||
horizontal: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-field" :class="{ horizontal, hasError: !!error }">
|
||||
<div v-if="label || $slots.label" class="label-row">
|
||||
<label v-if="label" :for="forId" class="field-label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="required-mark" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<slot v-else name="label" />
|
||||
<slot name="label-extra" />
|
||||
</div>
|
||||
|
||||
<div class="control-wrap">
|
||||
<slot />
|
||||
<p v-if="typeof error === 'string' && error" class="field-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="hint || $slots.hint" class="field-hint">
|
||||
<slot name="hint">{{ hint }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-field.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ui-field.horizontal .label-row {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
|
||||
.ui-field.horizontal .control-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: var(--danger);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.control-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@ const open = ref(false);
|
||||
<div class="row">
|
||||
<slot name="before" />
|
||||
<UiSearch v-model="search" :placeholder="placeholder" class="search" />
|
||||
<UiButton v-if="showFilters" type="button" @click="open = !open">
|
||||
<UiButton v-if="showFilters" variant="secondary" type="button" @click="open = !open">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
فیلترها
|
||||
</UiButton>
|
||||
@@ -54,9 +54,10 @@ const open = ref(false);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: var(--pad-modal);
|
||||
padding: var(--sp-4);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,48 +4,122 @@ withDefaults(
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
maxlength?: number;
|
||||
minlength?: number;
|
||||
error?: boolean | string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
maxlength?: number | string;
|
||||
minlength?: number | string;
|
||||
id?: string;
|
||||
autocomplete?: string;
|
||||
}>(),
|
||||
{ type: 'text' },
|
||||
{
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
const model = defineModel<string | number>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
class="ui-input"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
/>
|
||||
<div class="ui-input-wrapper" :class="[size, { disabled, hasError: !!error }]">
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
class="ui-input"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
:autocomplete="autocomplete"
|
||||
:aria-invalid="!!error"
|
||||
/>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-input {
|
||||
.ui-input-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-input:focus {
|
||||
.ui-input-wrapper.sm {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.md {
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.lg {
|
||||
min-height: var(--control-h-lg);
|
||||
padding: 0 var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-input::placeholder {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.ui-input-wrapper:focus-within {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.hasError {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.hasError:focus-within {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.disabled {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,7 @@ function onBackdrop(event: MouseEvent) {
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
@@ -72,55 +72,122 @@ function onBackdrop(event: MouseEvent) {
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.head,
|
||||
.body,
|
||||
.foot {
|
||||
padding: var(--pad-modal);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-top: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.body :deep(.crm-page.embedded),
|
||||
.body :deep(.page.embedded),
|
||||
.body :deep(.product-form-page.embedded),
|
||||
.body :deep(.company-form-page.embedded) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded > .form),
|
||||
.body :deep(.page.embedded > form),
|
||||
.body :deep(.crm-page.embedded > .form),
|
||||
.body :deep(.crm-page.embedded > form),
|
||||
.body :deep(.crm-page.embedded > .crm-form) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions),
|
||||
.body :deep(.embedded-actions),
|
||||
.body :deep(.form-bottom-actions),
|
||||
.body :deep(.form-footer-actions) {
|
||||
order: 99;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
margin-top: auto;
|
||||
margin-inline: -16px;
|
||||
margin-bottom: -16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--line);
|
||||
z-index: 10;
|
||||
box-shadow: 0 -4px 14px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions .btn),
|
||||
.body :deep(.embedded-actions .btn),
|
||||
.body :deep(.embed-actions .ui-btn),
|
||||
.body :deep(.embedded-actions .ui-btn),
|
||||
.body :deep(.form-bottom-actions .btn),
|
||||
.body :deep(.form-bottom-actions .ui-btn),
|
||||
.body :deep(.form-footer-actions .btn),
|
||||
.body :deep(.form-footer-actions .ui-btn) {
|
||||
border-radius: var(--radius, 10px);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -138,62 +205,15 @@ function onBackdrop(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.head {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.body:has(.page.embedded) {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded > .form),
|
||||
.body :deep(.page.embedded > form) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions) {
|
||||
order: 20;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
margin-inline: -1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions .btn) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
padding: 16px calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
lead?: string;
|
||||
description?: string;
|
||||
backTo?: string | object;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="ui-page-header">
|
||||
<div class="header-main">
|
||||
<RouterLink v-if="backTo" :to="backTo" class="back-link" title="بازگشت">
|
||||
<UiIcon name="arrow-right" :size="18" />
|
||||
</RouterLink>
|
||||
|
||||
<div class="titles">
|
||||
<h1 v-if="title" class="title">{{ title }}</h1>
|
||||
<slot v-else name="title" />
|
||||
|
||||
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
|
||||
<slot v-else name="lead" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink-secondary);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: var(--fw-extrabold);
|
||||
color: var(--ink);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ui-page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import UiButton from './UiButton.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number;
|
||||
totalPages?: number;
|
||||
totalItems?: number;
|
||||
pageSize?: number;
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number];
|
||||
change: [page: number];
|
||||
}>();
|
||||
|
||||
const effectiveTotalPages = computed(() => {
|
||||
if (props.totalPages != null) return Math.max(1, props.totalPages);
|
||||
if (props.totalItems != null && props.pageSize != null && props.pageSize > 0) {
|
||||
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
function goTo(p: number) {
|
||||
if (p < 1 || p > effectiveTotalPages.value || p === props.page) return;
|
||||
emit('update:page', p);
|
||||
emit('change', p);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="effectiveTotalPages > 1 || totalItems !== undefined" class="ui-pagination" aria-label="صفحهبندی">
|
||||
<div class="pagination-info">
|
||||
<span v-if="totalItems !== undefined">
|
||||
مجموع: <strong>{{ totalItems }}</strong> مورد
|
||||
</span>
|
||||
<span v-if="effectiveTotalPages > 1" class="page-count">
|
||||
(صفحه {{ page }} از {{ effectiveTotalPages }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveTotalPages > 1" class="pagination-controls">
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page <= 1"
|
||||
title="صفحه اول"
|
||||
@click="goTo(1)"
|
||||
>
|
||||
<UiIcon name="chevrons-right" :size="14" />
|
||||
</UiButton>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page <= 1"
|
||||
title="صفحه قبل"
|
||||
@click="goTo(page - 1)"
|
||||
>
|
||||
<UiIcon name="chevron-right" :size="14" />
|
||||
قبلی
|
||||
</UiButton>
|
||||
|
||||
<span class="current-indicator">
|
||||
{{ page }} / {{ effectiveTotalPages }}
|
||||
</span>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page >= effectiveTotalPages"
|
||||
title="صفحه بعد"
|
||||
@click="goTo(page + 1)"
|
||||
>
|
||||
بعدی
|
||||
<UiIcon name="chevron-left" :size="14" />
|
||||
</UiButton>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page >= effectiveTotalPages"
|
||||
title="صفحه آخر"
|
||||
@click="goTo(effectiveTotalPages)"
|
||||
>
|
||||
<UiIcon name="chevrons-left" :size="14" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.page-count {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.current-indicator {
|
||||
padding: 0 var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ui-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,12 +29,15 @@ const model = defineModel<string>({ default: '' });
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-search input {
|
||||
@@ -45,11 +48,14 @@ const model = defineModel<string>({ default: '' });
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-search:focus-within {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.sr {
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
error?: boolean | string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
id?: string;
|
||||
required?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'md',
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="ui-select">
|
||||
<select v-model="model" :disabled="disabled">
|
||||
<label class="ui-select" :class="[size, { disabled, hasError: !!error }]">
|
||||
<select :id="id" v-model="model" :disabled="disabled" :required="required" :aria-invalid="!!error">
|
||||
<slot />
|
||||
</select>
|
||||
<UiIcon class="chev" name="chevron" :size="16" />
|
||||
<UiIcon class="chev" name="chevron" :size="size === 'sm' ? 14 : 16" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -22,20 +33,45 @@ const model = defineModel<string>({ default: '' });
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 2rem 0 0.85rem;
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 2rem 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fw-medium);
|
||||
font-size: var(--fs-base);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-select.sm select {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 1.75rem 0 var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ui-select.md select {
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 2rem 0 var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.ui-select.lg select {
|
||||
min-height: var(--control-h-lg);
|
||||
padding: 0 2.25rem 0 var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.ui-select select:focus {
|
||||
@@ -43,6 +79,21 @@ const model = defineModel<string>({ default: '' });
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-select.hasError select {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-select.hasError select:focus {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-select.disabled select {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chev {
|
||||
position: absolute;
|
||||
left: 0.65rem;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
error?: boolean | string;
|
||||
rows?: number;
|
||||
maxlength?: number;
|
||||
id?: string;
|
||||
}>(),
|
||||
{
|
||||
rows: 3,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
class="ui-textarea"
|
||||
:class="{ disabled, hasError: !!error }"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:aria-invalid="!!error"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-normal);
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-textarea::placeholder {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.ui-textarea:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-textarea.hasError {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-textarea.hasError:focus {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-textarea.disabled {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
export { default as UiButton } from './UiButton.vue';
|
||||
export { default as UiInput } from './UiInput.vue';
|
||||
export { default as UiTextarea } from './UiTextarea.vue';
|
||||
export { default as UiSelect } from './UiSelect.vue';
|
||||
export { default as UiField } from './UiField.vue';
|
||||
export { default as UiCard } from './UiCard.vue';
|
||||
export { default as UiModal } from './UiModal.vue';
|
||||
export { default as UiPageHeader } from './UiPageHeader.vue';
|
||||
export { default as UiBadge } from './UiBadge.vue';
|
||||
export { default as UiPagination } from './UiPagination.vue';
|
||||
export { default as UiEmptyState } from './UiEmptyState.vue';
|
||||
export { default as UiSearch } from './UiSearch.vue';
|
||||
export { default as UiFilterBar } from './UiFilterBar.vue';
|
||||
export { default as UiSegmented } from './UiSegmented.vue';
|
||||
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
|
||||
@@ -70,12 +70,12 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* map 800 → Black */
|
||||
/* map 800 → Bold (avoid oversized heavy Black in UI) */
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src:
|
||||
url("../assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
|
||||
url("../assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
|
||||
url("../assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"),
|
||||
url("../assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff") format("woff");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -1,20 +1,131 @@
|
||||
@import "./fonts.css";
|
||||
@import "./tokens.css";
|
||||
|
||||
:root {
|
||||
/* Surface & Background */
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f1f5f9;
|
||||
--surface-hover: #f8fafc;
|
||||
|
||||
/* Typography & Foreground */
|
||||
--font: "IRANSans", Tahoma, sans-serif;
|
||||
--ink: #0f172a;
|
||||
--ink-secondary: #334155;
|
||||
--muted: #64748b;
|
||||
--muted-light: #94a3b8;
|
||||
--line: #e2e8f0;
|
||||
--line-light: #f1f5f9;
|
||||
--line-strong: #cbd5e1;
|
||||
|
||||
/* Accent / Primary (Teal) */
|
||||
--accent: #14b8a6;
|
||||
--accent-hover: #0d9488;
|
||||
--accent-deep: #0f766e;
|
||||
--accent-soft: #ccfbf1;
|
||||
--accent-subtle: #f0fdfa;
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
/* Semantics */
|
||||
--danger: #b42318;
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--danger-hover: #991b1b;
|
||||
--danger-soft: #fee2e2;
|
||||
--danger-deep: #7f1d1d;
|
||||
|
||||
--success: #10b981;
|
||||
--success-soft: #d1fae5;
|
||||
--success-deep: #047857;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-soft: #fef3c7;
|
||||
--warning-deep: #b45309;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-soft: #e0f2fe;
|
||||
--info-deep: #0369a1;
|
||||
|
||||
/* Typography Scale */
|
||||
--fs-xs: 0.75rem; /* 12px */
|
||||
--fs-sm: 0.8125rem; /* 13px */
|
||||
--fs-base: 0.875rem; /* 14px */
|
||||
--fs-md: 0.9375rem; /* 15px */
|
||||
--fs-lg: 1.0625rem; /* 17px */
|
||||
--fs-xl: 1.25rem; /* 20px */
|
||||
--fs-2xl: 1.5rem; /* 24px */
|
||||
--fs-3xl: 1.75rem; /* 28px */
|
||||
|
||||
--fw-normal: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
--fw-extrabold: 700;
|
||||
|
||||
--lh-tight: 1.25;
|
||||
--lh-normal: 1.5;
|
||||
--lh-relaxed: 1.625;
|
||||
|
||||
/* Spacing Scale */
|
||||
--sp-1: 0.25rem; /* 4px */
|
||||
--sp-2: 0.5rem; /* 8px */
|
||||
--sp-3: 0.75rem; /* 12px */
|
||||
--sp-4: 1rem; /* 16px */
|
||||
--sp-5: 1.25rem; /* 20px */
|
||||
--sp-6: 1.5rem; /* 24px */
|
||||
--sp-8: 2rem; /* 32px */
|
||||
--sp-10: 2.5rem; /* 40px */
|
||||
|
||||
/* Radius - Single Source of Truth (Globally 10px as requested) */
|
||||
--radius: 10px;
|
||||
--radius-xs: var(--radius);
|
||||
--radius-sm: var(--radius);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: var(--radius);
|
||||
--radius-2xl: var(--radius);
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Control Dimensions */
|
||||
--control-h-sm: 30px;
|
||||
--control-h: 36px;
|
||||
--pad-modal: 24px;
|
||||
--font: "IRANSans", Tahoma, sans-serif;
|
||||
--control-h-md: 36px;
|
||||
--control-h-lg: 42px;
|
||||
--pad-modal: 16px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
--shadow-xl: 0 20px 48px rgba(15, 23, 42, 0.16);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
|
||||
/* Z-index */
|
||||
--z-dropdown: 50;
|
||||
--z-sticky: 60;
|
||||
--z-modal-backdrop: 80;
|
||||
--z-modal: 90;
|
||||
--z-toast: 100;
|
||||
/* Compatibility aliases for legacy views */
|
||||
--color-border: var(--line);
|
||||
--color-border-light: var(--line-light);
|
||||
--color-text: var(--ink);
|
||||
--color-text-primary: var(--ink);
|
||||
--color-text-secondary: var(--ink-secondary);
|
||||
--color-text-muted: var(--muted);
|
||||
--color-text-emphasis: var(--ink);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-sunken: var(--surface-subtle);
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-dark: var(--accent-deep);
|
||||
--color-primary-light: var(--accent-soft);
|
||||
--color-primary-surface: var(--accent-subtle);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-surface: var(--danger-soft);
|
||||
--color-warning: var(--warning);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -64,16 +175,20 @@ textarea {
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 1rem;
|
||||
padding: 0 0.85rem;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Montact CRM (منتکت) Design System - Core Tokens & Utilities
|
||||
* RTL-first, IRANSans typography, unified spacing, controls & surfaces.
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Page Layout
|
||||
========================================================================== */
|
||||
.crm-page,
|
||||
.page,
|
||||
.dashboard,
|
||||
.profile,
|
||||
.notes-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.crm-page.narrow,
|
||||
.crm-page.wide,
|
||||
.crm-page.full {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.crm-page.embedded {
|
||||
padding: 0;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Page Header
|
||||
========================================================================== */
|
||||
.crm-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crm-page-header-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crm-page-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.crm-page-lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.crm-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Toolbar & Filter Bar
|
||||
========================================================================== */
|
||||
.crm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-toolbar-stretch,
|
||||
.crm-toolbar .toolbar-search,
|
||||
.crm-toolbar .search-box,
|
||||
.crm-toolbar .crm-search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.crm-toolbar .toolbar-select,
|
||||
.crm-toolbar .ui-select:not(.block) {
|
||||
width: auto;
|
||||
min-width: 130px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.crm-toolbar .ui-btn,
|
||||
.crm-toolbar .btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Card & Container Surfaces
|
||||
========================================================================== */
|
||||
.crm-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-xs);
|
||||
padding: var(--sp-5);
|
||||
box-sizing: border-box;
|
||||
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.crm-card-sm {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.crm-card-lg {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.crm-card-interactive:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.crm-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.crm-card-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.crm-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Forms & Field Layouts
|
||||
========================================================================== */
|
||||
.crm-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.crm-form-section-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
padding-bottom: var(--sp-2);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.crm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-form-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-form-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-form-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crm-col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.crm-col-span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.crm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crm-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.crm-required-indicator {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.crm-hint {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.crm-field-error {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.crm-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
margin-top: var(--sp-2);
|
||||
border-top: 1px solid var(--line-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Data Table
|
||||
========================================================================== */
|
||||
.crm-table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-xs);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.crm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: right;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.crm-table th {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-weight: var(--fw-semibold);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crm-table td {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
color: var(--ink);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.crm-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.crm-table tbody tr:hover td {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.crm-table-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Badges & Status Indicators
|
||||
========================================================================== */
|
||||
.crm-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.crm-badge-sm {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.crm-badge-neutral {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.crm-badge-accent,
|
||||
.crm-badge-primary {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.crm-badge-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
}
|
||||
|
||||
.crm-badge-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.crm-badge-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.crm-badge-info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info-deep);
|
||||
border-color: color-mix(in srgb, var(--info) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Pagination
|
||||
========================================================================== */
|
||||
.crm-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.crm-pagination-info {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.crm-pagination-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Empty States
|
||||
========================================================================== */
|
||||
.crm-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--sp-10) var(--sp-6);
|
||||
gap: var(--sp-3);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.crm-empty-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.crm-empty-desc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
max-width: 360px;
|
||||
line-height: var(--lh-relaxed);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Typography Utilities
|
||||
========================================================================== */
|
||||
.crm-text-xs { font-size: var(--fs-xs); }
|
||||
.crm-text-sm { font-size: var(--fs-sm); }
|
||||
.crm-text-base { font-size: var(--fs-base); }
|
||||
.crm-text-md { font-size: var(--fs-md); }
|
||||
.crm-text-lg { font-size: var(--fs-lg); }
|
||||
.crm-text-xl { font-size: var(--fs-xl); }
|
||||
.crm-text-2xl { font-size: var(--fs-2xl); }
|
||||
|
||||
.crm-font-normal { font-weight: var(--fw-normal); }
|
||||
.crm-font-medium { font-weight: var(--fw-medium); }
|
||||
.crm-font-semibold { font-weight: var(--fw-semibold); }
|
||||
.crm-font-bold { font-weight: var(--fw-bold); }
|
||||
.crm-font-extrabold { font-weight: var(--fw-extrabold); }
|
||||
|
||||
.crm-text-ink { color: var(--ink); }
|
||||
.crm-text-secondary { color: var(--ink-secondary); }
|
||||
.crm-text-muted { color: var(--muted); }
|
||||
.crm-text-accent { color: var(--accent); }
|
||||
.crm-text-danger { color: var(--danger); }
|
||||
.crm-text-success { color: var(--success); }
|
||||
.crm-text-warning { color: var(--warning); }
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Breakpoints
|
||||
========================================================================== */
|
||||
@media (max-width: 900px) {
|
||||
.crm-page {
|
||||
padding: 0;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.crm-page-title {
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.crm-page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.crm-header-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.crm-header-actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.crm-form-grid-2,
|
||||
.crm-form-grid-3,
|
||||
.crm-form-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.crm-col-span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.crm-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,7 @@ onMounted(() => {
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -461,7 +461,7 @@ h2 {
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.95rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
@@ -493,7 +493,7 @@ h2 {
|
||||
|
||||
.calendar {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
padding: 0.85rem;
|
||||
min-width: 0;
|
||||
@@ -519,7 +519,7 @@ h2 {
|
||||
.day {
|
||||
min-height: 92px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
padding: 0.45rem;
|
||||
text-align: right;
|
||||
@@ -553,7 +553,7 @@ h2 {
|
||||
}
|
||||
|
||||
.day.has .num {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@@ -594,7 +594,7 @@ h2 {
|
||||
|
||||
.day-panel {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
@@ -623,7 +623,7 @@ h2 {
|
||||
gap: 0.15rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
startOfLocalDay,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
@@ -124,8 +128,6 @@ const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))
|
||||
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
|
||||
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
const rangeFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
|
||||
const rangeTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
|
||||
|
||||
function isLastItem(id: string) {
|
||||
const rows = items.value;
|
||||
@@ -346,21 +348,19 @@ onMounted(() => {
|
||||
<span>فعالیتها</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">پیگیری</p>
|
||||
<h1>فعالیتها</h1>
|
||||
<p class="lead">تماس، جلسه و وظیفه — امروز و عقبافتادهها یکجا.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
|
||||
<UiPageHeader
|
||||
title="فعالیتها"
|
||||
lead="تماس، جلسه و وظیفه — امروز و عقبافتادهها یکجا."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel">
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</button>
|
||||
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</button>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -368,16 +368,18 @@ onMounted(() => {
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<UiButton type="button" @click="activityModal = {}">
|
||||
<UiButton variant="primary" @click="activityModal = {}">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
فعالیت جدید
|
||||
</UiButton>
|
||||
<RouterLink class="btn ghost" :to="{ name: 'activities-calendar' }">
|
||||
<UiIcon name="calendar" :size="18" />
|
||||
تقویم
|
||||
<RouterLink :to="{ name: 'activities-calendar' }">
|
||||
<UiButton variant="ghost">
|
||||
<UiIcon name="calendar" :size="16" />
|
||||
تقویم
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
|
||||
<button
|
||||
@@ -621,23 +623,13 @@ onMounted(() => {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer v-if="total" class="pager">
|
||||
<p>نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از {{ toFaDigits(total) }} فعالیت</p>
|
||||
<div class="pages">
|
||||
<button type="button" :disabled="page <= 1" @click="setPage(page - 1)">‹</button>
|
||||
<button
|
||||
v-for="n in pageCount"
|
||||
:key="n"
|
||||
type="button"
|
||||
:class="{ on: n === page }"
|
||||
@click="setPage(n)"
|
||||
>
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
<button type="button" :disabled="page >= pageCount" @click="setPage(page + 1)">›</button>
|
||||
</div>
|
||||
<p class="per">نمایش {{ toFaDigits(PAGE_SIZE) }} در هر صفحه</p>
|
||||
</footer>
|
||||
<UiPagination
|
||||
v-if="total"
|
||||
:page="page"
|
||||
:total-pages="pageCount"
|
||||
:total-items="total"
|
||||
@update:page="setPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<UiModal
|
||||
@@ -737,26 +729,34 @@ h1 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
padding: 0 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-color: #99f6e4;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.btn.wide {
|
||||
@@ -802,25 +802,38 @@ h1 {
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 400px;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0 0.85rem;
|
||||
color: #94a3b8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
padding: 0.75rem 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-wrap,
|
||||
@@ -839,7 +852,7 @@ h1 {
|
||||
inset-inline-end: 0;
|
||||
min-width: 11rem;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
@@ -859,7 +872,7 @@ h1 {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
@@ -874,7 +887,7 @@ h1 {
|
||||
|
||||
.empty-card {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
padding: 2.4rem 1.2rem 2rem;
|
||||
display: grid;
|
||||
@@ -914,7 +927,7 @@ h1 {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.8rem;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
@@ -933,7 +946,7 @@ h1 {
|
||||
|
||||
.table {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
@@ -950,9 +963,9 @@ h1 {
|
||||
.thead {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
border-radius: 18px 18px 0 0;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
|
||||
.trow {
|
||||
@@ -962,7 +975,7 @@ h1 {
|
||||
|
||||
.trow:last-child {
|
||||
border-bottom: 0;
|
||||
border-radius: 0 0 18px 18px;
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
|
||||
.trow.menu-open {
|
||||
@@ -982,7 +995,7 @@ h1 {
|
||||
|
||||
.title {
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1100,7 +1113,7 @@ time.note {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #64748b;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -1114,7 +1127,7 @@ time.note {
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 0.95rem 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1132,7 +1145,7 @@ time.note {
|
||||
.card-main time {
|
||||
display: inline-block;
|
||||
background: none;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
@@ -1224,7 +1237,7 @@ time.note {
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -313,13 +313,12 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 5.8rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -331,7 +330,7 @@ onMounted(load);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
@@ -341,7 +340,7 @@ onMounted(load);
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #334155;
|
||||
@@ -362,7 +361,7 @@ onMounted(load);
|
||||
inset-inline-end: 0;
|
||||
min-width: 10rem;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
@@ -377,7 +376,7 @@ onMounted(load);
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.62rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
@@ -407,7 +406,7 @@ h1 {
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { brand } from '@mento/shared';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityPriority,
|
||||
@@ -338,44 +345,44 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }" @click="typeOpen = assigneeOpen = personOpen = false">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit('detail')">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="desktop-head">
|
||||
<p class="crumb">
|
||||
<RouterLink :to="{ name: 'activities' }">فعالیتها</RouterLink>
|
||||
<span>‹</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</p>
|
||||
<div class="desk-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit('detail')">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
<section class="crm-page" :class="{ embedded }" @click="typeOpen = assigneeOpen = personOpen = false">
|
||||
<UiPageHeader
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
:back-to="{ name: 'activities' }"
|
||||
>
|
||||
<template #lead>
|
||||
<span class="crumb">
|
||||
<RouterLink :to="{ name: 'activities' }">فعالیتها</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
@click="submit('detail')"
|
||||
>
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||||
</button>
|
||||
<button class="btn outline" type="button" :disabled="saving" @click="submit('new')">
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="submit('new')"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
ذخیره و ایجاد جدید
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">
|
||||
<UiIcon name="x" :size="16" />
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header v-if="!embedded" class="mobile-head">
|
||||
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit('detail')">
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'main' }" @click="tab = 'main'">اطلاعات اصلی</button>
|
||||
@@ -410,7 +417,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="title">عنوان فعالیت <i>*</i></label>
|
||||
<input
|
||||
<UiInput
|
||||
id="title"
|
||||
v-model="title"
|
||||
required
|
||||
@@ -560,10 +567,10 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
@transcript="(text) => (description = appendSpokenText(description, text))"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
rows="8"
|
||||
:rows="8"
|
||||
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
||||
/>
|
||||
</div>
|
||||
@@ -603,114 +610,108 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
<div>
|
||||
<dt>وضعیت انجام</dt>
|
||||
<dd>{{ statusText }}</dd>
|
||||
<dd>
|
||||
<UiBadge :variant="isDone ? 'success' : 'neutral'" size="sm">
|
||||
{{ statusText }}
|
||||
</UiBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>اولویت</dt>
|
||||
<dd>{{ priorityText }}</dd>
|
||||
<dd>
|
||||
<UiBadge
|
||||
:variant="priority === 'very_high' || priority === 'high' ? 'danger' : priority === 'medium' ? 'warning' : 'neutral'"
|
||||
size="sm"
|
||||
>
|
||||
{{ priorityText }}
|
||||
</UiBadge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer v-if="!embedded" class="mobile-save">
|
||||
<button class="btn primary" type="button" :disabled="saving || loading" @click="submit('detail')">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="saving || loading"
|
||||
:loading="saving"
|
||||
@click="submit('detail')"
|
||||
>
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||||
</button>
|
||||
</UiButton>
|
||||
</footer>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
@click="submit('detail')"
|
||||
>
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">انصراف</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding-bottom: 5.5rem;
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
.crm-page.embedded {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.desktop-head {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.crumb {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #64748b;
|
||||
gap: var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: #0f766e;
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mobile-head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-head h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
gap: var(--sp-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0;
|
||||
padding: var(--sp-2) 0;
|
||||
font: inherit;
|
||||
color: #94a3b8;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tabs .on {
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -720,32 +721,34 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
.form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 0.95rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--sp-4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field label,
|
||||
.pri-label {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field i {
|
||||
color: #dc2626;
|
||||
color: var(--color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -830,7 +833,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
}
|
||||
|
||||
.who b {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who small {
|
||||
@@ -847,7 +850,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
@@ -861,7 +864,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
@@ -937,7 +940,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
@@ -1005,7 +1008,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
}
|
||||
|
||||
.pri.on span {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reminder :deep(.trigger) {
|
||||
@@ -1026,7 +1029,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
.channel-title {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -1036,7 +1039,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -1109,7 +1112,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.95rem;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1176,7 +1179,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
position: sticky;
|
||||
top: 0.8rem;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem 0.7rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
@@ -1195,7 +1198,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
justify-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 1rem 0.6rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import AutomationCanvas from '@/components/automations/AutomationCanvas.vue';
|
||||
import type { AutomationAddPayload } from '@/components/automations/AutomationCanvas.vue';
|
||||
@@ -138,39 +144,46 @@ function confirmLeave() {
|
||||
function startNew() {
|
||||
if (!confirmLeave()) return;
|
||||
hydrate(null);
|
||||
ok.value = '';
|
||||
}
|
||||
|
||||
function openRule(row: AutomationRuleDto) {
|
||||
if (editingId.value === row.id) return;
|
||||
if (!confirmLeave()) return;
|
||||
hydrate(row);
|
||||
ok.value = '';
|
||||
}
|
||||
|
||||
function addStep(payload: AutomationAddPayload, index: number) {
|
||||
if (steps.value.length >= MAX_AUTOMATION_STEPS) return;
|
||||
const step = payload.kind === 'condition' ? makeCondition() : makeStep(payload.action);
|
||||
if (steps.value.length >= MAX_AUTOMATION_STEPS) {
|
||||
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
const step =
|
||||
payload.kind === 'condition'
|
||||
? makeCondition()
|
||||
: makeStep(payload.action);
|
||||
steps.value.splice(index, 0, step);
|
||||
selected.value = step.id;
|
||||
}
|
||||
|
||||
function removeStep(id: string) {
|
||||
const target = steps.value.find((s) => s.id === id);
|
||||
if (!target) return;
|
||||
const actionCount = steps.value.filter((s) => !isAutomationCondition(s)).length;
|
||||
if (!isAutomationCondition(target) && actionCount <= 1) return;
|
||||
if (steps.value.length <= 1) return;
|
||||
steps.value = steps.value.filter((s) => s.id !== id);
|
||||
if (selected.value === id) selected.value = 'trigger';
|
||||
function removeStep(stepId: string) {
|
||||
if (steps.value.length <= 1) {
|
||||
error.value = 'حداقل یک مرحله باید در زنجیره باقی بماند.';
|
||||
return;
|
||||
}
|
||||
const i = steps.value.findIndex((s) => s.id === stepId);
|
||||
if (i < 0) return;
|
||||
steps.value.splice(i, 1);
|
||||
if (selected.value === stepId) {
|
||||
const next = steps.value[Math.max(0, i - 1)];
|
||||
selected.value = next?.id ?? 'trigger';
|
||||
}
|
||||
}
|
||||
|
||||
function chainLabel(row: AutomationRuleDto) {
|
||||
const chain = normalizeAutomationSteps(row.action, row.config);
|
||||
if (chain.length <= 1) {
|
||||
return `${AUTOMATION_TRIGGER_LABELS[row.trigger]} → ${AUTOMATION_ACTION_LABELS[row.action]}`;
|
||||
}
|
||||
return `${AUTOMATION_TRIGGER_LABELS[row.trigger]} → ${toFaDigits(chain.length)} عمل`;
|
||||
const t = AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger;
|
||||
const a = AUTOMATION_ACTION_LABELS[row.action] ?? row.action;
|
||||
return `${t} ← ${a}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -184,8 +197,9 @@ async function load() {
|
||||
items.value = rulesRes.data;
|
||||
users.value = usersRes.data;
|
||||
if (editingId.value) {
|
||||
const current = items.value.find((r) => r.id === editingId.value);
|
||||
if (current && !dirty.value) hydrate(current);
|
||||
const cur = items.value.find((x) => x.id === editingId.value);
|
||||
if (cur && !dirty.value) hydrate(cur);
|
||||
else if (!cur) hydrate(items.value[0] ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = errText(e, 'بارگذاری اتوماسیون ناموفق بود.');
|
||||
@@ -196,7 +210,7 @@ async function load() {
|
||||
|
||||
async function save() {
|
||||
const action = firstAutomationAction(steps.value);
|
||||
if (!canSave.value || !action) {
|
||||
if (!name.value.trim() || !action) {
|
||||
error.value = !action ? 'حداقل یک عمل در زنجیره لازم است.' : 'نام قانون را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
@@ -279,31 +293,31 @@ watch(trigger, (next) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>اتوماسیون گردش کار</h1>
|
||||
<p class="lead">
|
||||
مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید.
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn ghost" type="button" :disabled="loading" @click="load">
|
||||
<section class="crm-page">
|
||||
<UiPageHeader
|
||||
title="اتوماسیون"
|
||||
lead="مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</button>
|
||||
<button class="btn primary" type="button" @click="startNew">
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="startNew">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
قانون جدید
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<p v-if="ok" class="ok-msg">{{ ok }}</p>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="rules">
|
||||
<h2>قوانین</h2>
|
||||
<div class="rules-head">
|
||||
<h2>قوانین</h2>
|
||||
<UiBadge variant="neutral" size="sm">{{ items.length }} قانون</UiBadge>
|
||||
</div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="row in items"
|
||||
@@ -311,16 +325,21 @@ watch(trigger, (next) => {
|
||||
:class="{ on: editingId === row.id }"
|
||||
>
|
||||
<button class="pick" type="button" @click="openRule(row)">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ chainLabel(row) }}</span>
|
||||
<div class="pick-top">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<UiBadge :variant="row.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<span class="chain-preview">{{ chainLabel(row) }}</span>
|
||||
</button>
|
||||
<div class="mini">
|
||||
<button type="button" @click="toggleActive(row)">
|
||||
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</button>
|
||||
<button class="danger" type="button" @click="removeRule(row.id)">
|
||||
<UiButton variant="ghost" size="sm" @click="toggleActive(row)">
|
||||
{{ row.isActive ? 'غیرفعالسازی' : 'فعالسازی' }}
|
||||
</UiButton>
|
||||
<UiButton variant="danger" size="sm" @click="removeRule(row.id)">
|
||||
حذف
|
||||
</button>
|
||||
</UiButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -329,22 +348,23 @@ watch(trigger, (next) => {
|
||||
|
||||
<div class="studio">
|
||||
<div class="toolbar">
|
||||
<label class="name">
|
||||
نام قانون
|
||||
<input v-model="name" type="text" required minlength="2" maxlength="120" />
|
||||
</label>
|
||||
<div class="name-field">
|
||||
<span class="name-label">نام قانون:</span>
|
||||
<UiInput v-model="name" required minlength="2" maxlength="120" placeholder="نام قانون اتوماسیون…" />
|
||||
</div>
|
||||
<label class="check">
|
||||
<input v-model="isActive" type="checkbox" />
|
||||
فعال
|
||||
<span>فعال</span>
|
||||
</label>
|
||||
<button
|
||||
class="btn primary"
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="saving || !canSave"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ساخت' }}
|
||||
</button>
|
||||
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره قانون' : 'ساخت قانون' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
@@ -369,69 +389,53 @@ watch(trigger, (next) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted {
|
||||
.ok-msg {
|
||||
color: var(--color-success);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rules,
|
||||
.studio {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.rules {
|
||||
padding: 1rem;
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.rules-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.rules-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rules ul {
|
||||
@@ -439,7 +443,7 @@ h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.rules ul:empty {
|
||||
@@ -447,21 +451,22 @@ h2 {
|
||||
}
|
||||
|
||||
.rules li {
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 14px;
|
||||
padding: 0.55rem 0.65rem 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
gap: var(--sp-2);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rules li.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-surface);
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
gap: var(--sp-1);
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
@@ -469,80 +474,76 @@ h2 {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pick strong {
|
||||
font-size: 0.9rem;
|
||||
.pick-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pick span {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
.pick-top strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.chain-preview {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mini {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.mini button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mini .danger {
|
||||
color: #b91c1c;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.studio {
|
||||
padding: 0.85rem;
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
flex: 1 1 14rem;
|
||||
.name-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1 1 16rem;
|
||||
}
|
||||
|
||||
.name input {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
.name-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #334155;
|
||||
font-size: 0.88rem;
|
||||
padding-bottom: 0.45rem;
|
||||
gap: var(--sp-2);
|
||||
color: var(--color-text);
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -551,46 +552,9 @@ h2 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@@ -624,7 +588,7 @@ h2 {
|
||||
.toolbar {
|
||||
grid-area: toolbar;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 0.85rem 1rem;
|
||||
|
||||
@@ -4,7 +4,16 @@ import { RouterLink } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiChart,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
type UiChartOption,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { CommissionEntryDto } from '@mento/shared';
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -13,6 +22,65 @@ const items = ref<CommissionEntryDto[]>([]);
|
||||
|
||||
const posted = computed(() => items.value.filter((r) => r.status === 'posted'));
|
||||
const total = computed(() => posted.value.reduce((sum, r) => sum + r.amount, 0));
|
||||
const avgCommission = computed(() => (posted.value.length ? Math.round(total.value / posted.value.length) : 0));
|
||||
|
||||
const chartOption = computed<UiChartOption>(() => {
|
||||
const userMap = new Map<string, number>();
|
||||
for (const r of posted.value) {
|
||||
userMap.set(r.userName, (userMap.get(r.userName) || 0) + r.amount);
|
||||
}
|
||||
const names = Array.from(userMap.keys());
|
||||
const values = Array.from(userMap.values());
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown) => {
|
||||
const p = (params as Array<{ name: string; value: number }>)[0];
|
||||
if (!p) return '';
|
||||
return `${p.name}: <b>${formatMoney(p.value)}</b>`;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
left: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
color: '#64748b',
|
||||
formatter: (val: number) => (val >= 1000000 ? `${toFaDigits(String(val / 1000000))}M` : toFaDigits(String(val))),
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'پورسانت',
|
||||
type: 'bar',
|
||||
barWidth: '36%',
|
||||
data: values,
|
||||
itemStyle: {
|
||||
color: '#0f766e',
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
@@ -31,50 +99,99 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>پورسانت فروش</h1>
|
||||
<p class="sub">با صدور هر فاکتور برای کارشناس مالک فاکتور ثبت میشود</p>
|
||||
</div>
|
||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</header>
|
||||
<section class="crm-page commissions-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="پورسانت فروش"
|
||||
description="گزارش پورسانت ثبتشده برای کارشناسان فروش متصل به فاکتورهای صادره"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" :disabled="loading" @click="load">
|
||||
<template #icon>
|
||||
<UiIcon name="refresh" />
|
||||
</template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<article class="kpi">
|
||||
<span>جمع پورسانت ثبتشده</span>
|
||||
<strong>{{ formatMoney(total) }}</strong>
|
||||
<small>{{ toFaDigits(String(posted.length)) }} فاکتور</small>
|
||||
</article>
|
||||
<div class="kpi-grid">
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap primary">
|
||||
<UiIcon name="dollar" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">جمع پورسانت ثبتشده</span>
|
||||
<strong class="kpi-val">{{ formatMoney(total) }}</strong>
|
||||
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فاکتور تایید شده</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="!items.length" class="muted">هنوز پورسانتی ثبت نشده.</p>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap success">
|
||||
<UiIcon name="document" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">تعداد تراکنشها</span>
|
||||
<strong class="kpi-val">{{ toFaDigits(String(items.length)) }}</strong>
|
||||
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فعال / {{ toFaDigits(String(items.length - posted.length)) }} باطل</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap warning">
|
||||
<UiIcon name="trend" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">میانگین هر فاکتور</span>
|
||||
<strong class="kpi-val">{{ formatMoney(avgCommission) }}</strong>
|
||||
<small class="kpi-sub">بر اساس موارد فعال</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="posted.length > 0" title="سهم پورسانت به تفکیک کارشناسان" class="chart-card">
|
||||
<UiChart :option="chartOption" height="260px" />
|
||||
</UiCard>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading && !items.length"
|
||||
title="پورسانتی ثبت نشده است"
|
||||
description="هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
|
||||
icon="document"
|
||||
/>
|
||||
|
||||
<div v-else class="crm-table-container">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کارشناس</th>
|
||||
<th>فاکتور</th>
|
||||
<th>کارشناس فروش</th>
|
||||
<th>شماره و عنوان فاکتور</th>
|
||||
<th>درصد</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مبلغ پورسانت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td>{{ row.userName }}</td>
|
||||
<td class="font-medium text-primary">{{ row.userName }}</td>
|
||||
<td>
|
||||
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }">
|
||||
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }" class="table-link">
|
||||
{{ row.invoiceNumber }}
|
||||
</RouterLink>
|
||||
<small>{{ row.invoiceTitle }}</small>
|
||||
<span v-if="row.invoiceTitle" class="invoice-sub-title">{{ row.invoiceTitle }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<UiBadge variant="neutral">{{ toFaDigits(String(row.percent)) }}٪</UiBadge>
|
||||
</td>
|
||||
<td class="amount-cell">{{ formatMoney(row.amount, row.currency) }}</td>
|
||||
<td>
|
||||
<UiBadge :variant="row.status === 'posted' ? 'success' : 'neutral'">
|
||||
{{ row.status === 'posted' ? 'ثبتشده' : 'باطل' }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td>{{ toFaDigits(String(row.percent)) }}٪</td>
|
||||
<td>{{ formatMoney(row.amount, row.currency) }}</td>
|
||||
<td>{{ row.status === 'posted' ? 'ثبتشده' : 'باطل' }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -84,21 +201,104 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
h1 { margin: 0; font-size: 1.25rem; }
|
||||
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
|
||||
.error { color: var(--danger, #dc2626); font-weight: 600; }
|
||||
.kpi {
|
||||
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem; width: fit-content; min-width: 16rem;
|
||||
.commissions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.primary {
|
||||
background: var(--color-brand-50, #f0fdfa);
|
||||
color: var(--color-brand-600, #0f766e);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.success {
|
||||
background: var(--color-success-bg, #f0fdf4);
|
||||
color: var(--color-success-text, #15803d);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.warning {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning-text, #b45309);
|
||||
}
|
||||
|
||||
.kpi-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.kpi-val {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.kpi-sub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-weight: 700;
|
||||
color: var(--color-brand-700, #0f766e);
|
||||
}
|
||||
|
||||
.table-link {
|
||||
color: var(--color-brand-600, #0f766e);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.invoice-sub-title {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.kpi span, .kpi small { display: block; color: var(--muted); font-size: 0.8rem; }
|
||||
.kpi strong { display: block; margin: 0.25rem 0; font-size: 1.15rem; }
|
||||
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; vertical-align: top; }
|
||||
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
|
||||
td small { display: block; color: var(--muted); font-size: 0.78rem; }
|
||||
a { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,14 @@ import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -134,301 +142,187 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="desktop-head">
|
||||
<div class="title-block">
|
||||
<span class="doc-ico"><UiIcon name="building" :size="22" /></span>
|
||||
<div>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<p class="crumb">
|
||||
<RouterLink :to="{ name: 'contacts', query: { tab: 'companies' } }">شرکتها</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desk-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" :disabled="saving" @click="remove">
|
||||
<section class="crm-page company-form-page" :class="{ embedded }" dir="rtl">
|
||||
<UiPageHeader
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
description="ثبت و ویرایش مشخصات حقوقی، اطلاعات تماس و فیلدهای سفارشی شرکت"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<RouterLink :to="{ name: 'contacts', query: { tab: 'companies' } }" class="breadcrumb-link">
|
||||
شرکتها
|
||||
</RouterLink>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span>{{ pageTitle }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="submit">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
ذخیره
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<header v-if="!embedded" class="mobile-head">
|
||||
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<form v-else class="form" @submit.prevent="submit">
|
||||
<section class="card">
|
||||
<div class="grid">
|
||||
<div class="field wide">
|
||||
<label for="name">نام شرکت</label>
|
||||
<input id="name" v-model="name" required minlength="2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nationalId">شناسه ملی</label>
|
||||
<input id="nationalId" v-model="nationalId" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">تلفن</label>
|
||||
<input id="phone" v-model="phone" inputmode="tel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">ایمیل</label>
|
||||
<input id="email" v-model="email" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="website">وبسایت</label>
|
||||
<input id="website" v-model="website" />
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label for="address">آدرس</label>
|
||||
<input id="address" v-model="address" />
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label for="notes">یادداشت</label>
|
||||
<textarea id="notes" v-model="notes" rows="4" />
|
||||
</div>
|
||||
<form class="crm-form" @submit.prevent="submit">
|
||||
<UiCard title="اطلاعات اصلی شرکت" class="section-card">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام شرکت" required class="col-span-2">
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="name"
|
||||
required
|
||||
minlength="2"
|
||||
placeholder="مثال: دادهپردازی سپهر"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="شناسه ملی">
|
||||
<UiInput
|
||||
id="nationalId"
|
||||
v-model="nationalId"
|
||||
placeholder="شناسه ملی ۱۱ رقمی"
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="شماره تلفن شرکت">
|
||||
<UiInput
|
||||
id="phone"
|
||||
v-model="phone"
|
||||
type="tel"
|
||||
placeholder="۰۲۱..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="پست الکترونیکی (ایمیل)">
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="info@company.ir"
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="وبسایت">
|
||||
<UiInput
|
||||
id="website"
|
||||
v-model="website"
|
||||
placeholder="https://..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="آدرس پستی" class="col-span-2">
|
||||
<UiInput
|
||||
id="address"
|
||||
v-model="address"
|
||||
placeholder="نشانی، طبقه، پلاک..."
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="یادداشت و توضیحات تکمیلی" class="col-span-2">
|
||||
<UiTextarea
|
||||
id="notes"
|
||||
v-model="notes"
|
||||
:rows="4"
|
||||
placeholder="توضیحات مهم، حوزههای همکاری..."
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</section>
|
||||
</UiCard>
|
||||
|
||||
<CustomFieldsSection entity-type="company" v-model="customFields" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="mobile-actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" :disabled="saving" @click="remove">
|
||||
حذف
|
||||
</button>
|
||||
<div class="form-bottom-actions">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
ذخیره اطلاعات
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" type="button" :disabled="saving" @click="remove">
|
||||
حذف شرکت
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-width: 820px;
|
||||
min-width: 0;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
max-width: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
.company-form-page {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.desktop-head {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
margin: 0.2rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mobile-head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-head h1 {
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 0.95rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
.company-form-page.embedded {
|
||||
max-width: none;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
.embedded-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
.breadcrumb-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-brand-600);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
.breadcrumb-separator {
|
||||
margin: 0 var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
.col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
.form-bottom-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.desktop-head {
|
||||
display: flex;
|
||||
@media (max-width: 640px) {
|
||||
.col-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.mobile-head,
|
||||
.mobile-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
.form-bottom-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,13 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiEmptyState,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
@@ -57,18 +62,6 @@ const total = computed(() =>
|
||||
tab.value === 'people' ? people.value?.total ?? 0 : companies.value?.total ?? 0,
|
||||
);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||
const pageFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
|
||||
const pageTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
|
||||
const pageList = computed(() => {
|
||||
const count = pageCount.value;
|
||||
const current = page.value;
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= count; i += 1) {
|
||||
if (i === 1 || i === count || Math.abs(i - current) <= 2) pages.push(i);
|
||||
else if (pages[pages.length - 1] !== -1) pages.push(-1);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
function moodOf(person: PersonDto): PersonMood {
|
||||
return person.mood || 'newcomer';
|
||||
@@ -249,20 +242,19 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<section class="page" @click="closePopups">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>مخاطبین</h1>
|
||||
<p class="lead">مدیریت و مشاهده اشخاص و شرکتها</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
|
||||
<UiPageHeader
|
||||
title="مخاطبین"
|
||||
lead="مدیریت و مشاهده اشخاص و شرکتها"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="exportExcel">
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</button>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -271,14 +263,14 @@ onMounted(load);
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<UiButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'people' }" @click.stop="setTab('people')">
|
||||
@@ -442,7 +434,11 @@ onMounted(load);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="empty">شخصی ثبت نشده. اولین نفر را اضافه کنید.</p>
|
||||
<UiEmptyState
|
||||
v-else
|
||||
title="شخصی ثبت نشده است."
|
||||
description="اولین مخاطب یا مشتری را اضافه کنید."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -491,24 +487,20 @@ onMounted(load);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="empty">شرکتی ثبت نشده. اولین شرکت را اضافه کنید.</p>
|
||||
<UiEmptyState
|
||||
v-else
|
||||
title="شرکتی ثبت نشده است."
|
||||
description="اولین شرکت یا سازمان را اضافه کنید."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<footer v-if="total" class="pager">
|
||||
<span>
|
||||
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از {{ toFaDigits(total) }} مورد
|
||||
</span>
|
||||
<div class="pages">
|
||||
<button type="button" :disabled="page <= 1" @click="setPage(page - 1)">‹</button>
|
||||
<template v-for="(n, idx) in pageList" :key="`${n}-${idx}`">
|
||||
<span v-if="n < 0">…</span>
|
||||
<button v-else type="button" :class="{ on: n === page }" @click="setPage(n)">
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" :disabled="page >= pageCount" @click="setPage(page + 1)">›</button>
|
||||
</div>
|
||||
</footer>
|
||||
<UiPagination
|
||||
v-if="total"
|
||||
:page="page"
|
||||
:total-pages="pageCount"
|
||||
:total-items="total"
|
||||
@update:page="setPage"
|
||||
/>
|
||||
|
||||
<UiModal
|
||||
:open="Boolean(contactModal)"
|
||||
@@ -633,7 +625,7 @@ h1 {
|
||||
|
||||
.tabs .on {
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
|
||||
@@ -658,34 +650,44 @@ h1 {
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 14rem;
|
||||
min-width: 180px;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
color: #94a3b8;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool {
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
@@ -694,9 +696,16 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font: inherit;
|
||||
color: #475569;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tool:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.pop {
|
||||
@@ -712,7 +721,7 @@ h1 {
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
min-width: min(12rem, calc(100vw - 2rem));
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
@@ -725,7 +734,7 @@ h1 {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
color: inherit;
|
||||
@@ -751,7 +760,7 @@ h1 {
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -789,7 +798,7 @@ th {
|
||||
|
||||
.name {
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -814,7 +823,7 @@ th {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -869,7 +878,7 @@ strong.newcomer {
|
||||
gap: 0.7rem;
|
||||
align-items: start;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
min-width: 0;
|
||||
@@ -922,7 +931,7 @@ strong.newcomer {
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -972,7 +981,7 @@ strong.newcomer {
|
||||
padding: 1.4rem;
|
||||
color: #64748b;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.pager {
|
||||
@@ -997,7 +1006,7 @@ strong.newcomer {
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { CURRENCY_OPTIONS, formatMoney } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import type { OrganizationDto, UpdateOrgCurrenciesRequest } from '@mento/shared';
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -82,7 +89,7 @@ async function save() {
|
||||
body,
|
||||
);
|
||||
org.value = data;
|
||||
ok.value = 'تنظیمات ارز ذخیره شد.';
|
||||
ok.value = 'تنظیمات ارز با موفقیت ذخیره شد.';
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -101,223 +108,201 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>ارزها و نرخ تبدیل</h1>
|
||||
<p class="lead">
|
||||
ارز پایه برای جمعها و پیشبینی استفاده میشود. نرخ یعنی چند واحد ارز پایه
|
||||
برابر با ۱ واحد ارز خارجی است.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="crm-page currency-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="ارزها و نرخ تبدیل"
|
||||
description="ارز پایه برای تجمیع مبالغ و پیشبینیها استفاده میشود. نرخ یعنی چند واحد ارز پایه برابر با ۱ واحد ارز خارجی است."
|
||||
/>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
||||
|
||||
<form v-if="!loading" class="panel" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label>ارز پایه</label>
|
||||
<select v-model="baseCurrency" @change="onBaseChange">
|
||||
<option
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
:value="c.code"
|
||||
>
|
||||
{{ c.labelFa }} ({{ c.code }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<UiCard v-if="!loading" class="section-card">
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<UiField label="ارز پایه سیستم" hint="همه فاکتورها، پیشبینیها و آمار کل بر پایه این واحد ارزی محاسبه خواهند شد.">
|
||||
<UiSelect v-model="baseCurrency" @change="onBaseChange">
|
||||
<option
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
:value="c.code"
|
||||
>
|
||||
{{ c.labelFa }} ({{ c.code }})
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div class="field">
|
||||
<label>ارزهای فعال</label>
|
||||
<div class="chips">
|
||||
<label
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
class="chip"
|
||||
:class="{ on: enabled.includes(c.code), locked: c.code === baseCurrency }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled.includes(c.code)"
|
||||
:disabled="c.code === baseCurrency"
|
||||
@change="toggleCurrency(c.code)"
|
||||
/>
|
||||
{{ c.labelFa }}
|
||||
</label>
|
||||
<UiField label="ارزهای فعال در فاکتورها و معاملات">
|
||||
<div class="currency-chips">
|
||||
<label
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
class="currency-chip"
|
||||
:class="{ active: enabled.includes(c.code), locked: c.code === baseCurrency }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled.includes(c.code)"
|
||||
:disabled="c.code === baseCurrency"
|
||||
@change="toggleCurrency(c.code)"
|
||||
/>
|
||||
<span class="chip-title">{{ c.labelFa }}</span>
|
||||
<span class="chip-code font-mono">{{ c.code }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</UiField>
|
||||
|
||||
<div v-if="rateCurrencies.length" class="rates-section">
|
||||
<h3 class="rates-title">نرخ برابری نسبت به {{ baseCurrency }}</h3>
|
||||
<div class="rates-list">
|
||||
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
|
||||
<span class="rate-unit">۱ {{ code }} =</span>
|
||||
<div class="rate-input-wrap">
|
||||
<MoneyInput
|
||||
v-model="rates[code]"
|
||||
:decimals="6"
|
||||
:placeholder="`نرخ برابری به ${baseCurrency}`"
|
||||
/>
|
||||
</div>
|
||||
<span class="rate-target">{{ baseCurrency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="rates.USD" class="example-note">
|
||||
نمونه محاسبه: ۱ دلار آمریکا ≈ <strong>{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rateCurrencies.length" class="rates">
|
||||
<h2>نرخ به {{ baseCurrency }}</h2>
|
||||
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
|
||||
<span>۱ {{ code }} =</span>
|
||||
<MoneyInput
|
||||
v-model="rates[code]"
|
||||
:decimals="6"
|
||||
:placeholder="`نرخ به ${baseCurrency}`"
|
||||
/>
|
||||
<span>{{ baseCurrency }}</span>
|
||||
<div class="form-submit-row">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
ذخیره تنظیمات ارز
|
||||
</UiButton>
|
||||
</div>
|
||||
<p v-if="rates.USD" class="muted example">
|
||||
مثال: ۱ دلار ≈
|
||||
{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
.currency-page {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type='number'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chips {
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.currency-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
.currency-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
.currency-chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
}
|
||||
|
||||
.chip.locked {
|
||||
opacity: 0.85;
|
||||
.currency-chip.active {
|
||||
border-color: var(--color-brand-500);
|
||||
background: var(--color-brand-50, #f0fdfa);
|
||||
color: var(--color-brand-800, #115e59);
|
||||
}
|
||||
|
||||
.rates {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
.currency-chip.locked {
|
||||
opacity: 0.8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rates h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
.chip-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.rate-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.chip-code {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rates-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
background: var(--color-surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.rates-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.rate-input-wrap {
|
||||
flex: 1;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.rate-target {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.example-note {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-note strong {
|
||||
color: var(--color-brand-700, #0f766e);
|
||||
}
|
||||
|
||||
.form-submit-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
CreateCustomFieldRequest,
|
||||
CustomFieldDefinitionDto,
|
||||
@@ -13,7 +24,7 @@ const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const fields = ref<CustomFieldDefinitionDto[]>([]);
|
||||
const filter = ref<'all' | CustomFieldEntity>('all');
|
||||
const filter = ref<string>('all');
|
||||
|
||||
const label = ref('');
|
||||
const entityType = ref<CustomFieldEntity>('person');
|
||||
@@ -112,283 +123,256 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>فیلدهای سفارشی</h1>
|
||||
<p class="lead">
|
||||
فیلدهای اختصاصی سازمان را برای شخص، شرکت یا معامله تعریف کنید.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="crm-page narrow">
|
||||
<UiPageHeader
|
||||
title="فیلدهای سفارشی"
|
||||
lead="فیلدهای اختصاصی سازمان را برای شخص، شرکت یا معامله تعریف کنید."
|
||||
/>
|
||||
|
||||
<form class="panel" @submit.prevent="createField">
|
||||
<h2>فیلد جدید</h2>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="cf-label">عنوان</label>
|
||||
<input id="cf-label" v-model="label" required placeholder="مثلاً صنعت" />
|
||||
<form class="crm-form" @submit.prevent="createField">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<UiIcon name="settings" :size="18" />
|
||||
<h3>فیلد جدید</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="crm-form-grid-3">
|
||||
<UiField label="عنوان" required for-id="cf-label">
|
||||
<UiInput id="cf-label" v-model="label" required placeholder="مثلاً صنعت" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="روی چه چیزی؟" for-id="cf-entity">
|
||||
<UiSelect id="cf-entity" v-model="entityType">
|
||||
<option value="person">شخص</option>
|
||||
<option value="company">شرکت</option>
|
||||
<option value="deal">معامله</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="نوع فیلد" for-id="cf-type">
|
||||
<UiSelect id="cf-type" v-model="fieldType">
|
||||
<option value="text">متن</option>
|
||||
<option value="number">عدد</option>
|
||||
<option value="date">تاریخ</option>
|
||||
<option value="boolean">بله/خیر</option>
|
||||
<option value="select">لیست انتخابی</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div v-if="fieldType === 'select'" class="crm-col-span-full">
|
||||
<UiField label="گزینهها (با کاما جدا کنید)" for-id="cf-options">
|
||||
<UiInput
|
||||
id="cf-options"
|
||||
v-model="optionsRaw"
|
||||
placeholder="مثلاً فناوری، تولید، خدمات"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<div class="crm-col-span-full check-row">
|
||||
<label class="check">
|
||||
<input v-model="required" type="checkbox" />
|
||||
<span>فیلد اجباری است</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="cf-entity">روی چه چیزی؟</label>
|
||||
<select id="cf-entity" v-model="entityType">
|
||||
<option value="person">شخص</option>
|
||||
<option value="company">شرکت</option>
|
||||
<option value="deal">معامله</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="cf-type">نوع</label>
|
||||
<select id="cf-type" v-model="fieldType">
|
||||
<option value="text">متن</option>
|
||||
<option value="number">عدد</option>
|
||||
<option value="date">تاریخ</option>
|
||||
<option value="boolean">بله/خیر</option>
|
||||
<option value="select">لیست انتخابی</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field check-field">
|
||||
<label class="check">
|
||||
<input v-model="required" type="checkbox" />
|
||||
اجباری
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="fieldType === 'select'" class="field wide">
|
||||
<label for="cf-options">گزینهها (با کاما جدا کنید)</label>
|
||||
<input
|
||||
id="cf-options"
|
||||
v-model="optionsRaw"
|
||||
placeholder="مثلاً فناوری، تولید، خدمات"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
|
||||
</button>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiCard>
|
||||
</form>
|
||||
|
||||
<div class="toolbar">
|
||||
<select v-model="filter">
|
||||
<option value="all">همه</option>
|
||||
<div class="crm-toolbar">
|
||||
<UiSelect v-model="filter" class="filter-select">
|
||||
<option value="all">همه موجودیتها</option>
|
||||
<option value="person">شخص</option>
|
||||
<option value="company">شرکت</option>
|
||||
<option value="deal">معامله</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost" type="button" @click="load">بروزرسانی</button>
|
||||
</UiSelect>
|
||||
<UiButton variant="ghost" type="button" @click="load">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else class="list">
|
||||
<article v-for="field in visible" :key="field.id" class="row">
|
||||
<div>
|
||||
<strong>{{ field.label }}</strong>
|
||||
<p class="meta">
|
||||
{{ entityLabels[field.entityType] }} · {{ typeLabels[field.fieldType] }}
|
||||
· کلید: <code>{{ field.key }}</code>
|
||||
<span v-if="!field.isActive"> · غیرفعال</span>
|
||||
</p>
|
||||
<UiCard v-for="field in visible" :key="field.id" padding="sm" class="field-item-card">
|
||||
<div class="field-item">
|
||||
<div class="field-meta">
|
||||
<div class="field-title-line">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<UiBadge :variant="field.entityType === 'deal' ? 'warning' : field.entityType === 'company' ? 'info' : 'primary'" size="sm">
|
||||
{{ entityLabels[field.entityType] }}
|
||||
</UiBadge>
|
||||
<UiBadge variant="neutral" size="sm">
|
||||
{{ typeLabels[field.fieldType] }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="field.required" variant="danger" size="sm">
|
||||
اجباری
|
||||
</UiBadge>
|
||||
<UiBadge :variant="field.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ field.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<p class="meta-code">
|
||||
کلید سیستمی: <code>{{ field.key }}</code>
|
||||
<span v-if="field.options?.length" class="options-preview">
|
||||
· گزینهها: {{ field.options.join('، ') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleActive(field)"
|
||||
>
|
||||
{{ field.isActive ? 'غیرفعالسازی' : 'فعالسازی' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="removeField(field)"
|
||||
>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" type="button" @click="toggleActive(field)">
|
||||
{{ field.isActive ? 'غیرفعال' : 'فعال' }}
|
||||
</button>
|
||||
<button class="btn btn-danger" type="button" @click="removeField(field)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!visible.length" class="muted">هنوز فیلدی تعریف نشده است.</p>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!visible.length"
|
||||
icon="settings"
|
||||
title="فیلدی تعریف نشده است"
|
||||
description="هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 920px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
.card-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.meta {
|
||||
.card-title h3 {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.row {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type='text'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.check-field {
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
.filter-select {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border: 1px solid #99f6e4;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
box-shadow: none;
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
.field-item-card {
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.field-item-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.field-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.92rem;
|
||||
.field-meta {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.field-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-title-line strong {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.meta-code {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.meta-code code {
|
||||
font-family: Consolas, monospace;
|
||||
background: var(--color-surface-sunken);
|
||||
padding: 1px var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.options-preview {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row {
|
||||
@media (max-width: 640px) {
|
||||
.field-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+259
-252
@@ -12,6 +12,7 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader, UiSelect } from '@/components/ui';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateDealRequest,
|
||||
@@ -394,63 +395,45 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
function openNotes() {
|
||||
if (window.innerWidth < 960) tab.value = 'notes';
|
||||
document.getElementById('notes')?.focus();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }" @click="closeMenus">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header v-if="!embedded" class="desktop-head">
|
||||
<div class="title-block">
|
||||
<span class="doc-ico">
|
||||
<UiIcon name="document" :size="22" />
|
||||
<UiPageHeader
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
:back-to="{ name: 'pipeline' }"
|
||||
>
|
||||
<template #lead>
|
||||
<span class="crumb">
|
||||
<RouterLink :to="{ name: 'pipeline' }">کاریز فروش</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</span>
|
||||
<div>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<p class="crumb">
|
||||
<RouterLink :to="{ name: 'pipeline' }">کاریز فروش</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desk-actions">
|
||||
</template>
|
||||
<template #actions>
|
||||
<RouterLink
|
||||
v-if="!isNew && id"
|
||||
class="btn outline"
|
||||
:to="{ name: 'quote-new', query: { fromDealId: id } }"
|
||||
>
|
||||
صدور پیشفاکتور
|
||||
<UiButton variant="outline">صدور پیشفاکتور</UiButton>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="!isNew && id"
|
||||
class="btn outline"
|
||||
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
|
||||
>
|
||||
صدور فاکتور
|
||||
<UiButton variant="outline">صدور فاکتور</UiButton>
|
||||
</RouterLink>
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<header v-if="!embedded" class="mobile-head">
|
||||
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit">
|
||||
@@ -645,120 +628,116 @@ onMounted(load);
|
||||
|
||||
<section class="card extras">
|
||||
<button class="extras-toggle" type="button" @click="showOthers = !showOthers">
|
||||
سایر فیلدها
|
||||
<span class="extras-toggle-title">
|
||||
<UiIcon name="layers" :size="18" />
|
||||
سایر فیلدها و اطلاعات تکمیلی
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="16" :class="{ open: showOthers }" />
|
||||
</button>
|
||||
|
||||
<div v-if="showOthers" class="extras-grid">
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="dollar" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>واحد ارزی</small>
|
||||
<select v-model="currency">
|
||||
<div v-if="showOthers" class="extras-body">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="واحد ارزی">
|
||||
<UiSelect v-model="currency">
|
||||
<option v-for="code in enabledCurrencies" :key="code" :value="code">
|
||||
{{ currencySuffix(code) }}
|
||||
{{ currencySuffix(code) }} ({{ code }})
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</label>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<button class="mini" type="button" @click="productsOpen = !productsOpen">
|
||||
<span class="mini-ico"><UiIcon name="box" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>لیست محصولات</small>
|
||||
<strong>{{ productsLabel }}</strong>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="flag" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>کاریز</small>
|
||||
<select :value="pipelineId" @change="onPipelineChange(($event.target as HTMLSelectElement).value)">
|
||||
<UiField label="کاریز فروش">
|
||||
<UiSelect :model-value="pipelineId" @update:model-value="onPipelineChange($event)">
|
||||
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</label>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="layers" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>مرحله کاریز</small>
|
||||
<select v-model="stageId" required>
|
||||
<UiField label="مرحله کاریز" required>
|
||||
<UiSelect v-model="stageId" required>
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</label>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="target" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>وضعیت معامله</small>
|
||||
<select v-model="health">
|
||||
<UiField label="وضعیت سلامت معامله">
|
||||
<UiSelect v-model="health">
|
||||
<option value="normal">عادی</option>
|
||||
<option value="stale">راکد</option>
|
||||
<option value="rotten">فاسد</option>
|
||||
</select>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</label>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="users" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>شیوه آشنایی</small>
|
||||
<input v-model="source" placeholder="وبسایت، معرفی…" />
|
||||
</span>
|
||||
</label>
|
||||
<UiField label="شیوه آشنایی">
|
||||
<UiInput v-model="source" placeholder="مثال: وبسایت، معرفی، نمایشگاه…" />
|
||||
</UiField>
|
||||
|
||||
<label class="mini">
|
||||
<span class="mini-ico"><UiIcon name="filter" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>برچسبها</small>
|
||||
<input v-model="tagsRaw" placeholder="سازمانی، فوری" />
|
||||
</span>
|
||||
</label>
|
||||
<UiField label="برچسبها">
|
||||
<UiInput v-model="tagsRaw" placeholder="سازمانی، فوری (با کاما جدا کنید)" />
|
||||
</UiField>
|
||||
|
||||
<button class="mini notes-mini" type="button" @click="openNotes">
|
||||
<span class="mini-ico"><UiIcon name="chat" :size="16" /></span>
|
||||
<span class="mini-copy">
|
||||
<small>توضیحات و یادداشت</small>
|
||||
<strong>{{ notes.trim() ? 'ویرایش یادداشت' : 'افزودن یادداشت' }}</strong>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showOthers && productsOpen" class="lines-block">
|
||||
<div class="lines-head">
|
||||
<h3>محصولات / خدمات</h3>
|
||||
<button class="linkish" type="button" @click="addLine">افزودن ردیف</button>
|
||||
<UiField v-if="!isNew" label="دلیل شکست (در صورت باخت معامله)" class="crm-col-span-full">
|
||||
<UiInput id="lostReason" v-model="lostReason" placeholder="علت از دست رفتن معامله را بنویسید…" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div v-for="line in lines" :key="line.key" class="line-row">
|
||||
<select v-model="line.productId" @change="onPickProduct(line)">
|
||||
<option value="">محصول از کاتالوگ…</option>
|
||||
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
<input v-model="line.name" placeholder="نام ردیف" />
|
||||
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
|
||||
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
|
||||
<button class="linkish danger" type="button" @click="removeLine(line.key)">حذف</button>
|
||||
|
||||
<!-- بخش محصولات / خدمات -->
|
||||
<div class="products-section">
|
||||
<div class="products-head">
|
||||
<div class="products-head-info">
|
||||
<span class="sec-title">محصولات / خدمات</span>
|
||||
<span class="lines-count-badge">{{ productsLabel }}</span>
|
||||
</div>
|
||||
<div class="products-head-actions">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
@click="productsOpen = !productsOpen"
|
||||
>
|
||||
<UiIcon name="box" :size="14" />
|
||||
{{ productsOpen ? 'پنهان کردن اقلام' : 'مدیریت اقلام معامله' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="productsOpen"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
@click="addLine"
|
||||
>
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن ردیف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="productsOpen" class="lines-block">
|
||||
<div v-if="lines.length" class="lines-table">
|
||||
<div class="lines-table-head">
|
||||
<span>محصول از کاتالوگ</span>
|
||||
<span>نام ردیف</span>
|
||||
<span>تعداد</span>
|
||||
<span>قیمت واحد ({{ currencyLabel }})</span>
|
||||
<span>عملیات</span>
|
||||
</div>
|
||||
<div v-for="line in lines" :key="line.key" class="line-row">
|
||||
<UiSelect v-model="line.productId" @change="onPickProduct(line)">
|
||||
<option value="">محصول از کاتالوگ…</option>
|
||||
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</UiSelect>
|
||||
<UiInput v-model="line.name" placeholder="نام ردیف" />
|
||||
<UiInput v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
|
||||
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
|
||||
<button class="line-delete-btn" type="button" title="حذف ردیف" @click="removeLine(line.key)">
|
||||
<UiIcon name="trash" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="lines-empty-hint">
|
||||
میتوانید فقط مبلغ کل را در فیلد مبلغ وارد کنید یا با کلیک روی «افزودن ردیف»، اقلام را از کاتالوگ محصول مشخص فرمایید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!lines.length" class="muted">
|
||||
میتوانید فقط مبلغ کل را وارد کنید یا از کاتالوگ محصول ردیف اضافه کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showOthers && !isNew" class="field lost">
|
||||
<label for="lostReason">دلیل شکست (اگر از دست رفت)</label>
|
||||
<input id="lostReason" v-model="lostReason" />
|
||||
<CustomFieldsSection entity-type="deal" v-model="customFields" />
|
||||
</div>
|
||||
|
||||
<CustomFieldsSection v-if="showOthers" entity-type="deal" v-model="customFields" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -792,6 +771,16 @@ onMounted(load);
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -799,7 +788,8 @@ onMounted(load);
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding-bottom: 5.8rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -829,7 +819,7 @@ onMounted(load);
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecfdf5;
|
||||
@@ -900,7 +890,7 @@ h1 {
|
||||
|
||||
.tabs .on {
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
|
||||
@@ -914,7 +904,7 @@ h1 {
|
||||
.card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
min-width: 0;
|
||||
@@ -1039,7 +1029,7 @@ h1 {
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
color: #0f766e;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.55rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
@@ -1065,7 +1055,7 @@ h1 {
|
||||
min-height: calc(var(--control-h) - 4px);
|
||||
height: calc(var(--control-h) - 4px);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
@@ -1079,7 +1069,7 @@ h1 {
|
||||
.segment .on {
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu {
|
||||
@@ -1091,7 +1081,7 @@ h1 {
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
@@ -1110,7 +1100,7 @@ h1 {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
@@ -1127,7 +1117,7 @@ h1 {
|
||||
}
|
||||
|
||||
.who b {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who small {
|
||||
@@ -1150,7 +1140,7 @@ h1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
@@ -1163,141 +1153,167 @@ h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius, 10px);
|
||||
padding: 0.75rem 1rem;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.extras-toggle:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.extras-toggle-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.extras-toggle :deep(.ico) {
|
||||
color: #0f766e;
|
||||
transform: rotate(-90deg);
|
||||
color: var(--muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.extras-toggle :deep(.ico.open) {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.extras-grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.75rem;
|
||||
.extras-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
margin-top: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mini {
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
.products-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.products-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mini-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
flex-shrink: 0;
|
||||
.products-head-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mini-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
.sec-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.mini-copy small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
.lines-count-badge {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--line);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mini-copy strong {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mini select,
|
||||
.mini input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.mini :deep(.ico:last-child) {
|
||||
color: #cbd5e1;
|
||||
transform: rotate(90deg);
|
||||
flex-shrink: 0;
|
||||
.products-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.lines-block {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.lines-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.lines-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
.lines-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lines-table-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1.5fr 0.8fr 1.2fr 40px;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.line-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.line-row input,
|
||||
.line-row select,
|
||||
.lost input {
|
||||
grid-template-columns: 1.5fr 1.5fr 0.8fr 1.2fr 40px;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.lost {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
.line-delete-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.linkish.danger {
|
||||
color: #b91c1c;
|
||||
.line-delete-btn:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.lines-empty-hint {
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius, 10px);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lines-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.line-row {
|
||||
grid-template-columns: 1fr;
|
||||
background: var(--surface-subtle);
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius, 10px);
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.line-delete-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.note-card {
|
||||
@@ -1314,7 +1330,7 @@ h1 {
|
||||
min-height: 12rem;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.85rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
@@ -1345,7 +1361,7 @@ h1 {
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.95rem;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1447,15 +1463,6 @@ h1 {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.extras-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.line-row {
|
||||
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ import type {
|
||||
FaqArticleDto,
|
||||
UpdateFaqArticleRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -116,282 +127,204 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>پایگاه دانش (FAQ)</h1>
|
||||
<p class="lead">سؤالات پرتکرار برای کاهش تیکت تکراری.</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="crm-page faq-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="پایگاه دانش و سؤالات متداول"
|
||||
description="سؤالات پرتکرار و پاسخهای جامع برای تسریع در پاسخگویی پشتیبانان و کاهش تیکتهای مکرر"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
||||
|
||||
<form class="panel" @submit.prevent="save">
|
||||
<h2>{{ editingId ? 'ویرایش مقاله' : 'مقاله جدید' }}</h2>
|
||||
<div class="field">
|
||||
<label>سؤال</label>
|
||||
<input v-model="question" required maxlength="500" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>پاسخ</label>
|
||||
<textarea v-model="answer" rows="4" required />
|
||||
</div>
|
||||
<div class="row-fields">
|
||||
<div class="field">
|
||||
<label>دسته</label>
|
||||
<input v-model="category" maxlength="100" placeholder="مثلاً نصب" />
|
||||
<UiCard :title="editingId ? 'ویرایش مقاله' : 'مقاله جدید'" class="editor-card">
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<UiField label="پرسش / عنوان مقاله" required>
|
||||
<UiInput v-model="question" required maxlength="500" placeholder="مثلاً نحوه فعالسازی حساب کاربری..." />
|
||||
</UiField>
|
||||
|
||||
<UiField label="پاسخ کامل" required>
|
||||
<UiTextarea v-model="answer" :rows="4" required placeholder="توضیحات و راهنمای گامبهگام..." />
|
||||
</UiField>
|
||||
|
||||
<div class="form-grid-extra">
|
||||
<UiField label="دستهبندی">
|
||||
<UiInput v-model="category" maxlength="100" placeholder="مثلاً مالی، فنی، نصب" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="ترتیب نمایش">
|
||||
<UiInput v-model.number="position" type="number" min="0" />
|
||||
</UiField>
|
||||
|
||||
<div class="publish-toggle-wrap">
|
||||
<label class="publish-label">
|
||||
<input v-model="isPublished" type="checkbox" />
|
||||
<span>منتشرشده در پورتال</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ترتیب</label>
|
||||
<input v-model.number="position" type="number" min="0" />
|
||||
</div>
|
||||
<label class="check">
|
||||
<input v-model="isPublished" type="checkbox" />
|
||||
منتشرشده
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? '…' : editingId ? 'ذخیره تغییرات' : 'افزودن' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="resetForm"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="row in items" :key="row.id" class="card">
|
||||
<header>
|
||||
<strong>{{ row.question }}</strong>
|
||||
<span v-if="!row.isPublished" class="tag muted-tag">پیشنویس</span>
|
||||
<span v-if="row.category" class="tag">{{ row.category }}</span>
|
||||
</header>
|
||||
<p>{{ row.answer }}</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn ghost" type="button" @click="startEdit(row)">
|
||||
<div class="form-actions-row">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
{{ editingId ? 'ذخیره تغییرات' : 'افزودن مقاله' }}
|
||||
</UiButton>
|
||||
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !items.length"
|
||||
title="هنوز مقالهای ثبت نشده است"
|
||||
description="با ثبت اولین سؤال و پاسخ، پایگاه دانش سازمان را تکمیل کنید."
|
||||
icon="document"
|
||||
/>
|
||||
|
||||
<div v-else class="articles-list">
|
||||
<UiCard v-for="row in items" :key="row.id" class="article-card">
|
||||
<div class="article-content">
|
||||
<div class="article-title-row">
|
||||
<strong class="article-question">{{ row.question }}</strong>
|
||||
<div class="article-badges">
|
||||
<UiBadge :variant="row.isPublished ? 'success' : 'neutral'">
|
||||
{{ row.isPublished ? 'منتشرشده' : 'پیشنویس' }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="row.category" variant="info">
|
||||
{{ row.category }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<p class="article-answer">{{ row.answer }}</p>
|
||||
</div>
|
||||
|
||||
<div class="article-actions">
|
||||
<UiButton variant="secondary" size="sm" @click="startEdit(row)">
|
||||
<template #icon>
|
||||
<UiIcon name="pencil" />
|
||||
</template>
|
||||
ویرایش
|
||||
</button>
|
||||
<button class="btn danger" type="button" @click="remove(row)">
|
||||
</UiButton>
|
||||
<UiButton variant="danger" size="sm" @click="remove(row)">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
حذف
|
||||
</button>
|
||||
</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!loading && !items.length" class="empty">هنوز مقالهای نیست.</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 720px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
.faq-page {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
.editor-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.list {
|
||||
.form-grid-extra {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.row-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: var(--sp-4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.row-fields .field {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.check {
|
||||
.publish-toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: inherit;
|
||||
padding-bottom: 0.5rem;
|
||||
height: var(--control-h, 40px);
|
||||
}
|
||||
|
||||
.btn {
|
||||
.publish-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-color: #99f6e4;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.card-actions {
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
.articles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card header {
|
||||
.article-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.article-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0 0 0.65rem;
|
||||
white-space: pre-wrap;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
color: #0f766e;
|
||||
.article-question {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.muted-tag {
|
||||
color: #64748b;
|
||||
.article-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
.article-answer {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--lh-relaxed);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
.article-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-grid-extra {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.row-fields {
|
||||
.article-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.row-fields .field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.card-actions {
|
||||
flex-direction: column;
|
||||
.article-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,15 @@ import { formatMoney } from '@/lib/money';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
InventorySummaryDto,
|
||||
StockMovementDto,
|
||||
@@ -103,78 +110,83 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>انبار</h1>
|
||||
<p class="sub">
|
||||
موجودی کالا، ورود خرید و مانده وقتی تهحساب وصل نباشد
|
||||
</p>
|
||||
</div>
|
||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</header>
|
||||
<section class="crm-page">
|
||||
<UiPageHeader
|
||||
title="انبار"
|
||||
lead="موجودی کالا، ورود خرید و مانده وقتی تهحساب وصل نباشد"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="summary && !summary.enabled" class="notice">
|
||||
<div v-else-if="summary && !summary.enabled" class="notice-box">
|
||||
اتصال تهحساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده میشود.
|
||||
</div>
|
||||
|
||||
<template v-else-if="summary">
|
||||
<div class="kpis">
|
||||
<article>
|
||||
<span>صندوق (وصولها)</span>
|
||||
<strong>{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>مانده طرفحسابها</span>
|
||||
<strong>{{
|
||||
formatMoney(summary.receivablesTotal, summary.currency)
|
||||
}}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>انبار</span>
|
||||
<strong>{{ summary.warehouseName }}</strong>
|
||||
</article>
|
||||
<div class="kpis-grid">
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">صندوق (وصولها)</span>
|
||||
<strong class="kpi-value">{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
|
||||
</UiCard>
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">مانده طرفحسابها</span>
|
||||
<strong class="kpi-value">{{ formatMoney(summary.receivablesTotal, summary.currency) }}</strong>
|
||||
</UiCard>
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">نام انبار</span>
|
||||
<strong class="kpi-value">{{ summary.warehouseName }}</strong>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<form v-if="canWrite" class="move-form" @submit.prevent="submitMove">
|
||||
<select v-model="moveType">
|
||||
<option value="purchase">ورود خرید</option>
|
||||
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
|
||||
</select>
|
||||
<select v-model="productId">
|
||||
<option value="" disabled>محصول</option>
|
||||
<option
|
||||
v-for="p in summary.products"
|
||||
:key="p.productId"
|
||||
:value="p.productId"
|
||||
>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
<UiInput
|
||||
v-model="quantity"
|
||||
type="number"
|
||||
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
|
||||
/>
|
||||
<UiInput v-model="note" placeholder="توضیح (اختیاری)" />
|
||||
<UiButton type="submit" :disabled="saving || !productId">
|
||||
{{ saving ? 'ثبت…' : 'ثبت گردش' }}
|
||||
</UiButton>
|
||||
</form>
|
||||
<UiCard v-if="canWrite" padding="sm" class="move-form-card">
|
||||
<form class="move-form" @submit.prevent="submitMove">
|
||||
<UiSelect v-model="moveType">
|
||||
<option value="purchase">ورود خرید</option>
|
||||
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
|
||||
</UiSelect>
|
||||
<UiSelect v-model="productId">
|
||||
<option value="" disabled>انتخاب محصول</option>
|
||||
<option
|
||||
v-for="p in summary.products"
|
||||
:key="p.productId"
|
||||
:value="p.productId"
|
||||
>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
<UiInput
|
||||
v-model="quantity"
|
||||
type="number"
|
||||
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
|
||||
/>
|
||||
<UiInput v-model="note" placeholder="توضیح اختیاری…" />
|
||||
<UiButton variant="primary" type="submit" :disabled="saving || !productId" :loading="saving">
|
||||
ثبت گردش
|
||||
</UiButton>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>موجودی کالا</h2>
|
||||
<p v-if="!summary.products.length" class="muted">محصولی نیست.</p>
|
||||
<table v-else>
|
||||
<div class="inventory-grid">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="section-title">
|
||||
<UiIcon name="box" :size="18" />
|
||||
<h3>موجودی کالا</h3>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!summary.products.length" class="muted empty-note">محصولی در انبار تعریف نشده است.</p>
|
||||
<table v-else class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کالا</th>
|
||||
<th>نام کالا</th>
|
||||
<th>واحد</th>
|
||||
<th>موجودی</th>
|
||||
</tr>
|
||||
@@ -183,190 +195,185 @@ onMounted(load);
|
||||
<tr v-for="p in summary.products" :key="p.productId">
|
||||
<td>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<small v-if="p.sku">{{ p.sku }}</small>
|
||||
<small v-if="p.sku" class="sku-hint">{{ p.sku }}</small>
|
||||
</td>
|
||||
<td>{{ p.unit || 'عدد' }}</td>
|
||||
<td class="num" :class="{ low: p.stockQty <= 0 }">
|
||||
{{ toFaDigits(String(p.stockQty)) }}
|
||||
<td>
|
||||
<UiBadge :variant="p.stockQty > 0 ? 'success' : 'danger'" size="sm">
|
||||
{{ toFaDigits(String(p.stockQty)) }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<div class="card">
|
||||
<h2>گردش اخیر</h2>
|
||||
<p v-if="!movements.length" class="muted">گردشی ثبت نشده.</p>
|
||||
<ul v-else class="moves">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="section-title">
|
||||
<UiIcon name="clock" :size="18" />
|
||||
<h3>گردش اخیر کالا</h3>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!movements.length" class="muted empty-note">هیچ گردشی ثبت نشده است.</p>
|
||||
<ul v-else class="moves-list">
|
||||
<li v-for="m in movements" :key="m.id">
|
||||
<div>
|
||||
<div class="move-info">
|
||||
<strong>{{ m.productName }}</strong>
|
||||
<span>{{ typeLabel[m.type] }}</span>
|
||||
<small v-if="m.note">{{ m.note }}</small>
|
||||
<span class="move-type-tag">{{ typeLabel[m.type] }}</span>
|
||||
<small v-if="m.note" class="move-note">{{ m.note }}</small>
|
||||
</div>
|
||||
<div class="move-end">
|
||||
<UiBadge :variant="m.quantity > 0 ? 'success' : 'danger'" size="sm">
|
||||
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
|
||||
</UiBadge>
|
||||
<time class="move-time">{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
|
||||
</div>
|
||||
<em :class="{ in: m.quantity > 0, out: m.quantity < 0 }">
|
||||
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
|
||||
</em>
|
||||
<time>{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sub,
|
||||
.muted {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
.notice-box {
|
||||
background: var(--color-info-surface);
|
||||
border: 1px solid var(--color-info);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
color: var(--color-info);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
.kpis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.kpis article,
|
||||
.card,
|
||||
.move-form {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem;
|
||||
.kpi-value {
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kpis span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.kpis strong {
|
||||
font-size: 1.05rem;
|
||||
.move-form-card {
|
||||
margin: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.move-form {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 1fr 8rem 1fr auto;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.move-form select {
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.6rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
td small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.num {
|
||||
.section-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.low {
|
||||
color: var(--danger, #dc2626);
|
||||
.sku-hint {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.moves {
|
||||
.empty-note {
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.moves-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.moves li {
|
||||
.moves-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.move-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.15rem 0.75rem;
|
||||
font-size: 0.86rem;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.moves span,
|
||||
.moves small,
|
||||
.moves time {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
.move-info strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.moves em {
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
.move-type-tag {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.moves .in {
|
||||
color: #16a34a;
|
||||
.move-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.moves .out {
|
||||
color: #dc2626;
|
||||
.move-end {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.move-time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpis,
|
||||
.grid,
|
||||
.kpis-grid,
|
||||
.inventory-grid,
|
||||
.move-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -845,12 +845,6 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embed-actions no-print">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="goBack">انصراف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="page-head no-print">
|
||||
<div class="head-copy">
|
||||
<div class="title-row">
|
||||
@@ -1725,6 +1719,14 @@ onMounted(load);
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions no-print">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="goBack">انصراف</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1741,7 +1743,8 @@ onMounted(load);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
@@ -1795,7 +1798,7 @@ onMounted(load);
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--teal-soft);
|
||||
@@ -1806,7 +1809,7 @@ onMounted(load);
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1849,13 +1852,13 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-ico {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--teal-soft);
|
||||
@@ -1877,7 +1880,7 @@ h1 {
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
@@ -2017,7 +2020,7 @@ h1 {
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -2064,7 +2067,7 @@ h1 {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
@@ -2183,7 +2186,7 @@ h1 {
|
||||
.prod-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--teal-soft);
|
||||
@@ -2230,7 +2233,7 @@ h1 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -2253,7 +2256,7 @@ h1 {
|
||||
}
|
||||
|
||||
.line-sum {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2261,7 +2264,7 @@ h1 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
display: grid;
|
||||
@@ -2278,7 +2281,7 @@ h1 {
|
||||
background: var(--teal-soft);
|
||||
color: var(--teal);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -2287,7 +2290,7 @@ h1 {
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
max-width: 360px;
|
||||
margin-inline-start: auto;
|
||||
@@ -2311,7 +2314,7 @@ h1 {
|
||||
.sum-row.payable {
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--teal-soft);
|
||||
color: var(--teal);
|
||||
border: 1px solid var(--teal-border);
|
||||
@@ -2382,7 +2385,7 @@ h1 {
|
||||
background: var(--teal-soft);
|
||||
color: var(--teal);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.icon-add {
|
||||
@@ -2425,7 +2428,7 @@ h1 {
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -2448,7 +2451,7 @@ h1 {
|
||||
|
||||
.mob-total {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -2531,7 +2534,7 @@ h1 {
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@@ -2606,7 +2609,7 @@ h1 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.page {
|
||||
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiEmptyState,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import InvoiceFormView from '@/views/InvoiceFormView.vue';
|
||||
import type {
|
||||
@@ -41,7 +46,6 @@ type BillingRow = {
|
||||
href: { name: string; params: { id: string } };
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50];
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -216,12 +220,6 @@ const pageItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value;
|
||||
return filtered.value.slice(start, start + pageSize.value);
|
||||
});
|
||||
const pageFrom = computed(() =>
|
||||
filtered.value.length ? (page.value - 1) * pageSize.value + 1 : 0,
|
||||
);
|
||||
const pageTo = computed(() =>
|
||||
Math.min(page.value * pageSize.value, filtered.value.length),
|
||||
);
|
||||
|
||||
function docLabel(doc: BillingRow['doc']) {
|
||||
if (doc === 'quote') return 'پیش فاکتور';
|
||||
@@ -343,35 +341,33 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<section class="page" @click="closeMenus">
|
||||
<header class="page-head">
|
||||
<div class="title-row">
|
||||
<span class="title-ico"><UiIcon name="document" :size="22" /></span>
|
||||
<div>
|
||||
<h1>فاکتور ها</h1>
|
||||
<p class="lead">مدیریت فاکتورهای فروش، پیش فاکتور و خدمات</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions" @click.stop>
|
||||
<div class="add-split desk-only">
|
||||
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
افزودن فاکتور
|
||||
</UiButton>
|
||||
<button type="button" class="btn primary split" aria-label="گزینههای افزودن" @click="addOpen = !addOpen">
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
<div v-if="addOpen" class="menu">
|
||||
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
|
||||
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
|
||||
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
|
||||
<UiPageHeader
|
||||
title="فاکتور ها"
|
||||
lead="مدیریت فاکتورهای فروش، پیش فاکتور و خدمات"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="head-actions" @click.stop>
|
||||
<div class="add-split desk-only">
|
||||
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
افزودن فاکتور
|
||||
</UiButton>
|
||||
<button type="button" class="btn primary split" aria-label="گزینههای افزودن" @click="addOpen = !addOpen">
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
<div v-if="addOpen" class="menu">
|
||||
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
|
||||
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
|
||||
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<!-- Desktop underline tabs -->
|
||||
<div class="tabs desk-only">
|
||||
@@ -565,37 +561,21 @@ onMounted(load);
|
||||
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!pageItems.length" class="empty">سندی یافت نشد.</p>
|
||||
<UiEmptyState
|
||||
v-if="!pageItems.length"
|
||||
title="سندی یافت نشد."
|
||||
description="فاکتور، پیشفاکتور یا سند جدیدی ثبت کنید."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer v-if="filtered.length" class="pager">
|
||||
<div class="pager-meta">
|
||||
<p class="muted">
|
||||
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
|
||||
{{ toFaDigits(filtered.length) }} مورد
|
||||
</p>
|
||||
<label class="page-size desk-only">
|
||||
نمایش در هر صفحه:
|
||||
<select v-model.number="pageSize">
|
||||
<option v-for="n in PAGE_SIZES" :key="n" :value="n">{{ toFaDigits(n) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pages">
|
||||
<button type="button" class="page-btn" :disabled="page <= 1" @click="page -= 1">‹</button>
|
||||
<button
|
||||
v-for="n in pageCount"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:class="{ on: page === n }"
|
||||
@click="page = n"
|
||||
>
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
<button type="button" class="page-btn" :disabled="page >= pageCount" @click="page += 1">›</button>
|
||||
</div>
|
||||
</footer>
|
||||
<UiPagination
|
||||
v-if="filtered.length"
|
||||
:page="page"
|
||||
:total-pages="pageCount"
|
||||
:total-items="filtered.length"
|
||||
:page-size="pageSize"
|
||||
@update:page="page = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<UiModal
|
||||
@@ -682,7 +662,8 @@ onMounted(load);
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
position: relative;
|
||||
padding-bottom: 5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
@@ -692,10 +673,10 @@ onMounted(load);
|
||||
}
|
||||
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||||
.title-ico {
|
||||
width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center;
|
||||
width: 44px; height: 44px; border-radius: var(--radius); display: grid; place-items: center;
|
||||
background: var(--teal-soft); color: var(--teal);
|
||||
}
|
||||
h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
|
||||
h1 { margin: 0; font-size: 1.5rem; font-weight: 600; }
|
||||
.lead { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||
.head-actions { position: relative; }
|
||||
.add-split { display: flex; position: relative; }
|
||||
@@ -721,11 +702,11 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
|
||||
.add-doc { color: var(--teal); }
|
||||
.plus-badge {
|
||||
position: absolute; bottom: 6px; left: 6px;
|
||||
background: var(--teal); color: #fff; border-radius: 4px; padding: 1px;
|
||||
background: var(--teal); color: #fff; border-radius: var(--radius); padding: 1px;
|
||||
}
|
||||
.menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 170px;
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 12px;
|
||||
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: 0 12px 28px rgba(15,23,42,.12); padding: 0.35rem; display: grid;
|
||||
}
|
||||
.menu.left { left: auto; right: 0; }
|
||||
@@ -733,7 +714,7 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
|
||||
.menu button {
|
||||
display: block; width: 100%;
|
||||
border: 0; background: transparent; text-align: right; padding: 0.55rem 0.7rem;
|
||||
border-radius: 8px; color: var(--text); text-decoration: none; cursor: pointer;
|
||||
border-radius: var(--radius); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.menu a:hover,
|
||||
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
|
||||
@@ -764,21 +745,30 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
|
||||
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.search {
|
||||
flex: 1 1 220px; display: flex; align-items: center; gap: 0.45rem;
|
||||
flex: 1 1 220px; max-width: 380px; display: flex; align-items: center; gap: 0.45rem;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||||
padding: 0 0.75rem; background: #fff; color: var(--muted); box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--text); }
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--ink); font-size: var(--fs-sm); padding: 0; }
|
||||
.search .mob-ph { display: none; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
|
||||
.filter-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; flex-wrap: nowrap;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.55rem;
|
||||
background: #fff; color: #94a3b8; min-height: var(--control-h); height: var(--control-h);
|
||||
box-sizing: border-box; white-space: nowrap;
|
||||
background: #fff; color: var(--muted); min-height: var(--control-h); height: var(--control-h);
|
||||
box-sizing: border-box; white-space: nowrap; font-size: var(--fs-sm);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.filter-chip:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.filter-chip select, .filter-chip :deep(button), .filter-chip :deep(input) {
|
||||
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--text);
|
||||
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--ink); font-size: var(--fs-sm);
|
||||
}
|
||||
.filter-chip :deep(.wrap) { width: auto; flex: 0 1 auto; }
|
||||
.filter-chip :deep(.trigger) {
|
||||
@@ -790,7 +780,7 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
|
||||
.filter-chip .sep { color: #cbd5e1; flex-shrink: 0; }
|
||||
|
||||
.table-wrap {
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; overflow: auto;
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); overflow: auto;
|
||||
box-shadow: 0 8px 24px rgba(15,23,42,.04);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; min-width: 980px; }
|
||||
@@ -802,7 +792,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.cust { display: flex; gap: 0.55rem; align-items: center; }
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: 999px; background: var(--teal-soft); color: var(--teal);
|
||||
display: grid; place-items: center; font-weight: 800; flex-shrink: 0;
|
||||
display: grid; place-items: center; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.cust strong { display: block; }
|
||||
.cust small { color: var(--muted); }
|
||||
@@ -832,7 +822,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.mob-list { display: none; gap: 0.65rem; }
|
||||
.mob-card {
|
||||
position: relative;
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 0.9rem;
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.9rem;
|
||||
box-shadow: 0 6px 16px rgba(15,23,42,.04);
|
||||
display: grid; gap: 0.65rem;
|
||||
}
|
||||
@@ -847,7 +837,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
|
||||
.mob-menu { top: 2.2rem; }
|
||||
.mob-card-bottom { display: flex; justify-content: flex-start; }
|
||||
.mob-card-bottom em { font-style: normal; font-weight: 800; font-size: 0.92rem; }
|
||||
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
|
||||
|
||||
.pager {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center;
|
||||
@@ -857,11 +847,11 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.88rem;
|
||||
}
|
||||
.page-size select {
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 0.3rem 0.5rem; background: #fff;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.3rem 0.5rem; background: #fff;
|
||||
}
|
||||
.pages { display: flex; gap: 0.3rem; }
|
||||
.page-btn {
|
||||
min-width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--line);
|
||||
min-width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid var(--line);
|
||||
background: #fff; cursor: pointer;
|
||||
}
|
||||
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||||
@@ -869,7 +859,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.fab {
|
||||
display: none; position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 40;
|
||||
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
|
||||
border-radius: 14px; padding: 0.9rem; font-weight: 800;
|
||||
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
|
||||
box-shadow: 0 12px 28px rgba(13,148,136,.35);
|
||||
}
|
||||
|
||||
@@ -878,12 +868,12 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
display: grid; align-items: end;
|
||||
}
|
||||
.sheet {
|
||||
background: #fff; border-radius: 20px 20px 0 0; padding: 1rem; max-height: 85vh; overflow: auto;
|
||||
background: #fff; border-radius: var(--radius, 10px) var(--radius, 10px) 0 0; padding: 16px; max-height: 85vh; overflow: auto;
|
||||
}
|
||||
.sheet header {
|
||||
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
|
||||
}
|
||||
.sheet header strong { justify-self: end; }
|
||||
.sheet header strong { justify-self: end; font-size: 16px; font-weight: 500; color: var(--ink); }
|
||||
.link { border: 0; background: transparent; color: var(--teal); font-weight: 700; cursor: pointer; }
|
||||
.lbl { margin: 0.85rem 0 0.4rem; font-weight: 700; color: #334155; }
|
||||
.pill-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
@@ -894,7 +884,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.pill-row button.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||||
.field { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
|
||||
.field input, .field select, .control select {
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
|
||||
}
|
||||
.control { position: relative; display: flex; align-items: center; }
|
||||
.control :deep(.ico) {
|
||||
|
||||
@@ -557,7 +557,7 @@ onMounted(load);
|
||||
<style scoped>
|
||||
.page { --teal: #14b8a6; display: grid; gap: 0.85rem; }
|
||||
.top { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
h1 { margin: 0; font-size: 1.55rem; font-weight: 800; display: inline-flex; gap: 0.35rem; align-items: center; }
|
||||
h1 { margin: 0; font-size: 1.55rem; font-weight: 600; display: inline-flex; gap: 0.35rem; align-items: center; }
|
||||
h1 :deep(.ico) { color: var(--teal); }
|
||||
.top p, .back { color: #64748b; }
|
||||
.back { background: none; border: 0; font: inherit; cursor: pointer; padding: 0; }
|
||||
@@ -565,31 +565,31 @@ h1 :deep(.ico) { color: var(--teal); }
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.3rem; min-height: var(--control-h); height: var(--control-h); padding: 0 0.9rem; border-radius: var(--radius); font: inherit; font-weight: 700; border: 1px solid transparent; cursor: pointer; text-decoration: none; box-sizing: border-box; }
|
||||
.btn.primary { background: var(--teal); color: #fff; }
|
||||
.btn.ghost { background: #fff; color: #0f766e; border-color: #99f6e4; }
|
||||
.banner { margin: 0; padding: 0.6rem 0.8rem; border-radius: 12px; }
|
||||
.banner { margin: 0; padding: 0.6rem 0.8rem; border-radius: var(--radius); }
|
||||
.banner.err { background: #fef2f2; color: #b91c1c; }
|
||||
.banner.ok { background: #ecfdf5; color: #047857; }
|
||||
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; background: #fff; padding: 0.35rem; border-radius: 14px; }
|
||||
.tabs button { border: 0; background: transparent; min-height: 38px; padding: 0 0.85rem; border-radius: 10px; font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; background: #fff; padding: 0.35rem; border-radius: var(--radius); }
|
||||
.tabs button { border: 0; background: transparent; min-height: 38px; padding: 0 0.85rem; border-radius: var(--radius); font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.tabs button.on { background: var(--teal); color: #fff; }
|
||||
.workspace { display: grid; grid-template-columns: 280px minmax(0,1fr) 240px; gap: 0.85rem; align-items: start; }
|
||||
.preview-col, .canvas, .toolbox, .panel { background: #fff; border-radius: 16px; padding: 0.9rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); }
|
||||
.preview-col, .canvas, .toolbox, .panel { background: #fff; border-radius: var(--radius); padding: 0.9rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); }
|
||||
.device-toggle { display: flex; gap: 0.3rem; margin-bottom: 0.7rem; }
|
||||
.device-toggle button { flex: 1; border: 0; min-height: 34px; border-radius: 10px; font: inherit; font-weight: 700; background: #f1f5f9; cursor: pointer; }
|
||||
.device-toggle button { flex: 1; border: 0; min-height: 34px; border-radius: var(--radius); font: inherit; font-weight: 700; background: #f1f5f9; cursor: pointer; }
|
||||
.device-toggle button.on { background: var(--teal); color: #fff; }
|
||||
.phone { border: 8px solid #0f172a; border-radius: 28px; overflow: hidden; background: #fff; max-width: 260px; margin: 0 auto; }
|
||||
.phone { border: 8px solid #0f172a; border-radius: var(--radius); overflow: hidden; background: #fff; max-width: 260px; margin: 0 auto; }
|
||||
.desk-overlay { padding: 1.2rem; }
|
||||
.desk-sheet {
|
||||
width: min(920px, 100%);
|
||||
max-height: calc(100vh - 2.4rem);
|
||||
overflow: auto;
|
||||
background: #eef2f6;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.desk-sheet header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.desk-form { background: #fff; border-radius: 18px; overflow: hidden; box-shadow: 0 16px 40px rgba(15,23,42,.08); }
|
||||
.desk-form { background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 16px 40px rgba(15,23,42,.08); }
|
||||
.desk-form .cover { height: 140px; }
|
||||
.desk-form .cover img { width: 100%; height: 140px; object-fit: cover; }
|
||||
.desk-body { padding: 1.2rem 1.4rem 1.4rem; }
|
||||
@@ -603,26 +603,26 @@ h1 :deep(.ico) { color: var(--teal); }
|
||||
.preview-body h3 { margin: 0; font-size: 1rem; }
|
||||
.preview-body p, .preview-body small { margin: 0; color: #64748b; font-size: 0.78rem; }
|
||||
.preview-body label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: #475569; }
|
||||
.preview-body input, .preview-body textarea, .preview-body select { border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.45rem 0.6rem; font: inherit; background: #f8fafc; }
|
||||
.preview-body input, .preview-body textarea, .preview-body select { border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.45rem 0.6rem; font: inherit; background: #f8fafc; }
|
||||
.send { border: 0; color: #fff; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h); font: inherit; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; box-sizing: border-box; }
|
||||
.opts, .stars { color: #64748b; font-size: 0.75rem; display: grid; gap: 0.2rem; }
|
||||
.cover-box { width: 100%; min-height: 88px; border: 1.5px dashed #99f6e4; border-radius: 14px; background: #f0fdfa; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; overflow: hidden; }
|
||||
.cover-box { width: 100%; min-height: 88px; border: 1.5px dashed #99f6e4; border-radius: var(--radius); background: #f0fdfa; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; overflow: hidden; }
|
||||
.cover-box img { width: 100%; height: 88px; object-fit: cover; display: block; }
|
||||
.title-card, .field-card, .submit-card, .adv-box { border: 1px solid #eef2f7; border-radius: 14px; padding: 0.8rem; display: grid; gap: 0.45rem; }
|
||||
.title { font-size: 1.15rem; font-weight: 800; border: 0; background: transparent; font: inherit; }
|
||||
.title-card, .field-card, .submit-card, .adv-box { border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.8rem; display: grid; gap: 0.45rem; }
|
||||
.title { font-size: 1.15rem; font-weight: 600; border: 0; background: transparent; font: inherit; }
|
||||
.title-card textarea, .submit-card input, .panel input, .panel select, .sheet input, .sheet select, .sheet textarea {
|
||||
border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.55rem 0.7rem; font: inherit; width: 100%;
|
||||
border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.55rem 0.7rem; font: inherit; width: 100%;
|
||||
}
|
||||
.field-card { grid-template-columns: auto 1fr auto; align-items: center; cursor: grab; }
|
||||
.field-card small { color: #94a3b8; display: block; }
|
||||
.fops { display: flex; gap: 0.2rem; }
|
||||
.fops button { width: 30px; height: 30px; border: 0; border-radius: 8px; background: #f8fafc; cursor: pointer; color: #64748b; }
|
||||
.fops button { width: 30px; height: 30px; border: 0; border-radius: var(--radius); background: #f8fafc; cursor: pointer; color: #64748b; }
|
||||
.fops .danger { color: #dc2626; }
|
||||
.add-field { width: 100%; min-height: var(--control-h); height: var(--control-h); border: 1.5px dashed #14b8a6; background: #f0fdfa; color: #0f766e; border-radius: var(--radius); font: inherit; font-weight: 700; cursor: pointer; box-sizing: border-box; }
|
||||
.adv { background: none; border: 0; font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; text-align: right; }
|
||||
.toolbox h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
|
||||
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
.tools button, .ready div button { border: 1px solid #eef2f7; background: #f8fafc; border-radius: 10px; min-height: 52px; font: inherit; font-size: 0.75rem; cursor: pointer; display: grid; place-items: center; gap: 0.15rem; color: #334155; }
|
||||
.tools button, .ready div button { border: 1px solid #eef2f7; background: #f8fafc; border-radius: var(--radius); min-height: 52px; font: inherit; font-size: 0.75rem; cursor: pointer; display: grid; place-items: center; gap: 0.15rem; color: #334155; }
|
||||
.ready { border-bottom: 1px solid #f1f5f9; }
|
||||
.ready > button { width: 100%; background: none; border: 0; text-align: right; font: inherit; font-weight: 700; padding: 0.55rem 0; cursor: pointer; }
|
||||
.panel { display: grid; gap: 0.7rem; }
|
||||
@@ -635,7 +635,7 @@ h1 :deep(.ico) { color: var(--teal); }
|
||||
.row input { flex: 1; }
|
||||
.sub { border-bottom: 1px solid #f1f5f9; padding: 0.5rem 0; }
|
||||
.overlay { position: fixed; inset: 0; background: rgba(15,23,42,.35); display: grid; place-items: center; z-index: 40; padding: 1rem; }
|
||||
.sheet { width: min(420px,100%); background: #fff; border-radius: 16px; padding: 1rem; display: grid; gap: 0.65rem; }
|
||||
.sheet { width: min(420px,100%); background: #fff; border-radius: var(--radius); padding: 1rem; display: grid; gap: 0.65rem; }
|
||||
.link { background: none; border: 0; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
@media (max-width: 1100px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -177,7 +177,7 @@ onMounted(load);
|
||||
.panel {
|
||||
width: min(440px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { LeadFormDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
@@ -59,59 +66,186 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>
|
||||
فرمساز
|
||||
<UiIcon name="layers" :size="22" />
|
||||
</h1>
|
||||
<p class="lead">فرم وب بسازید، فیلدها را بچینید و پاسخها را در باشگاه مشتریان و کاریز ببینید.</p>
|
||||
</div>
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="createForm">
|
||||
+ فرم جدید
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else class="muted">{{ countLabel }} فرم</p>
|
||||
<section class="crm-page lead-forms-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="فرمساز وب"
|
||||
description="فرمهای آنلاین بسازید، فیلدها را بچینید و لیدها را به صورت خودکار در کاریز و باشگاه مشتریان دریافت کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="createForm">
|
||||
<template #icon>
|
||||
<UiIcon name="plus" />
|
||||
</template>
|
||||
فرم جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="form in forms" :key="form.id" class="card">
|
||||
<div>
|
||||
<strong>{{ form.name }}</strong>
|
||||
<p>{{ form.isActive ? 'فعال' : 'غیرفعال' }} · {{ toFaDigits(form.fields.length) }} فیلد · {{ toFaDigits(form.submissionCount ?? 0) }} پاسخ</p>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
|
||||
<div v-else-if="!loading && forms.length" class="forms-summary">
|
||||
<span class="forms-count-badge">{{ countLabel }} فرم ساخته شده</span>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !forms.length"
|
||||
title="هنوز فرمی نساختهاید"
|
||||
description="با ساخت اولین فرم، میتوانید در وبسایت یا شبکههای اجتماعی سرنخ و مشتری جذب کنید."
|
||||
icon="document"
|
||||
>
|
||||
<template #action>
|
||||
<UiButton :loading="saving" @click="createForm">
|
||||
<template #icon>
|
||||
<UiIcon name="plus" />
|
||||
</template>
|
||||
ساخت اولین فرم
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="forms-list">
|
||||
<UiCard v-for="form in forms" :key="form.id" class="form-card">
|
||||
<div class="form-info">
|
||||
<div class="form-title-row">
|
||||
<strong class="form-name">{{ form.name }}</strong>
|
||||
<UiBadge :variant="form.isActive ? 'success' : 'neutral'">
|
||||
{{ form.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<p class="form-meta">
|
||||
<span>{{ toFaDigits(form.fields.length) }} فیلد سفارشی</span>
|
||||
<span class="meta-dot">·</span>
|
||||
<span>{{ toFaDigits(form.submissionCount ?? 0) }} ارسال شده</span>
|
||||
<span v-if="form.source" class="meta-dot">·</span>
|
||||
<span v-if="form.source">منبع: {{ form.source }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<RouterLink class="btn ghost" :to="{ name: 'lead-form-builder', params: { id: form.id } }">ویرایش</RouterLink>
|
||||
<a class="btn ghost" :href="form.publicPath" target="_blank" rel="noopener">پیشنمایش</a>
|
||||
<button class="link danger" type="button" @click="removeForm(form)">حذف</button>
|
||||
|
||||
<div class="form-actions">
|
||||
<RouterLink :to="{ name: 'lead-form-builder', params: { id: form.id } }">
|
||||
<UiButton variant="secondary" size="sm">
|
||||
<template #icon>
|
||||
<UiIcon name="pencil" />
|
||||
</template>
|
||||
ویرایش فیلدها
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
|
||||
<a :href="form.publicPath" target="_blank" rel="noopener" class="preview-link">
|
||||
<UiButton variant="ghost" size="sm">
|
||||
<template #icon>
|
||||
<UiIcon name="eye" />
|
||||
</template>
|
||||
پیشنمایش
|
||||
</UiButton>
|
||||
</a>
|
||||
|
||||
<UiButton variant="danger" size="sm" @click="removeForm(form)">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!loading && !forms.length" class="empty">هنوز فرمی نساختهاید.</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.page-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
h1 { margin: 0; font-size: clamp(1.45rem, 2.4vw, 1.85rem); font-weight: 800; display: inline-flex; gap: 0.4rem; align-items: center; }
|
||||
h1 :deep(.ico) { color: #14b8a6; }
|
||||
.lead, .muted { margin: 0.25rem 0 0; color: #64748b; }
|
||||
.err { color: #b91c1c; }
|
||||
.btn, .link { font: inherit; cursor: pointer; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-h); height: var(--control-h); padding: 0 0.95rem; border-radius: var(--radius); font-weight: 700; border: 1px solid transparent; text-decoration: none; box-sizing: border-box; }
|
||||
.btn.primary { background: #14b8a6; color: #fff; }
|
||||
.btn.ghost { background: #fff; color: #0f766e; border-color: #99f6e4; }
|
||||
.link { background: none; border: 0; font-weight: 700; }
|
||||
.danger { color: #b91c1c; }
|
||||
.list { display: grid; gap: 0.75rem; }
|
||||
.card { background: #fff; border-radius: 16px; padding: 1rem 1.05rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||||
.card p { margin: 0.25rem 0 0; color: #64748b; font-size: 0.88rem; }
|
||||
.ops { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
|
||||
.empty { padding: 1rem; border: 1px dashed #e2e8f0; border-radius: 16px; color: #64748b; text-align: center; }
|
||||
@media (max-width: 700px) {
|
||||
.page-head, .card, .ops { flex-direction: column; align-items: stretch; }
|
||||
.lead-forms-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.forms-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forms-count-badge {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-soft);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.forms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-card:hover {
|
||||
border-color: var(--color-brand-300, #5eead4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.form-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.form-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.form-name {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-meta {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -350,7 +350,7 @@ function cancelOrgChoice() {
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
min-height: min(640px, calc(100dvh - 5.5rem));
|
||||
@@ -374,7 +374,7 @@ function cancelOrgChoice() {
|
||||
|
||||
.mobile-brand strong {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
@@ -408,14 +408,14 @@ h1 {
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.1rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--line) 55%, white);
|
||||
}
|
||||
|
||||
.mode-tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 11px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
@@ -440,7 +440,7 @@ h1 {
|
||||
|
||||
.input-wrap input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
@@ -518,7 +518,7 @@ h1 {
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ h1 {
|
||||
.dev-hint {
|
||||
margin: -0.4rem 0 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: #0f766e;
|
||||
font-size: 0.88rem;
|
||||
|
||||
@@ -1033,7 +1033,7 @@ onBeforeUnmount(() => {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.45rem, 2.4vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1043,7 +1043,7 @@ h1 {
|
||||
.crown :deep(.ico) { fill: #eab308; stroke: #ca8a04; }
|
||||
.lead, .sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||
h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
.banner { margin: 0; padding: 0.65rem 0.85rem; border-radius: 12px; font-weight: 600; }
|
||||
.banner { margin: 0; padding: 0.65rem 0.85rem; border-radius: var(--radius); font-weight: 600; }
|
||||
.banner.err { color: #b91c1c; background: #fef2f2; }
|
||||
.banner.ok { color: #047857; background: #ecfdf5; }
|
||||
.banner.warn { color: #b45309; background: #fffbeb; }
|
||||
@@ -1055,20 +1055,20 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
}
|
||||
.kpis.mobile { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
|
||||
.kpi {
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.kpi strong { font-size: 1.55rem; font-weight: 800; color: var(--ink); line-height: 1.1; }
|
||||
.kpi strong { font-size: 1.55rem; font-weight: 600; color: var(--ink); line-height: 1.1; }
|
||||
.kpi span { color: var(--muted); font-size: 0.82rem; }
|
||||
.kpi.teal { background: #e7f7f4; }
|
||||
.kpi.purple { background: #f3eefc; }
|
||||
.kpi.gold { background: #fff6df; }
|
||||
.kpi.blue { background: #e8f3fb; }
|
||||
.kpi-ico {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
width: 34px; height: 34px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; margin-bottom: 0.15rem;
|
||||
}
|
||||
.kpi.teal .kpi-ico { background: #c7efe8; color: #0f766e; }
|
||||
@@ -1093,7 +1093,7 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
gap: 0.4rem;
|
||||
background: #fff;
|
||||
padding: 0.35rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.tabs button {
|
||||
@@ -1102,7 +1102,7 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
color: var(--teal-deep);
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
@@ -1115,7 +1115,7 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem 1.1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
display: grid;
|
||||
@@ -1141,26 +1141,33 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
padding: 0 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
box-sizing: border-box;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.btn.primary { background: var(--teal); color: #fff; box-shadow: 0 8px 16px rgba(20, 184, 166, 0.22); }
|
||||
.btn.primary { background: var(--accent); color: #fff; }
|
||||
.btn.ghost, .btn.settings {
|
||||
background: #fff;
|
||||
color: var(--teal-deep);
|
||||
border-color: #99f6e4;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.btn.sm { min-height: 34px; padding: 0.3rem 0.7rem; font-size: 0.85rem; }
|
||||
.btn.ghost:hover, .btn.settings:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.btn.sm { min-height: 30px; height: 30px; padding: 0 0.65rem; font-size: var(--fs-xs); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.icon-btn {
|
||||
width: 40px; height: 40px; border-radius: 12px;
|
||||
border: 1px solid #e2e8f0; background: #fff; color: #64748b;
|
||||
display: grid; place-items: center;
|
||||
width: var(--control-h); height: var(--control-h); border-radius: var(--radius);
|
||||
border: 1px solid var(--line); background: #fff; color: var(--muted);
|
||||
display: grid; place-items: center; box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.icon-btn.on { background: #ccfbf1; border-color: #99f6e4; color: #0f766e; }
|
||||
.icon-btn.on { background: var(--accent-subtle); border-color: var(--accent); color: var(--accent-deep); }
|
||||
.text-link {
|
||||
background: none; border: 0; color: var(--teal); font-weight: 700; padding: 0; justify-self: start;
|
||||
}
|
||||
@@ -1173,7 +1180,7 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
}
|
||||
.level-card {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
@@ -1185,7 +1192,7 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
.level-card.silver { background: linear-gradient(180deg, #f3f6fa, #fff); }
|
||||
.level-card.bronze { background: linear-gradient(180deg, #fff3e6, #fff); }
|
||||
.lvl-ico {
|
||||
width: 42px; height: 42px; border-radius: 12px;
|
||||
width: 42px; height: 42px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; margin-bottom: 0.2rem;
|
||||
}
|
||||
.level-card.gold .lvl-ico { background: #ffe58a; color: #ca8a04; }
|
||||
@@ -1232,18 +1239,29 @@ h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
|
||||
}
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
border: 1px solid #e2e8f0; border-radius: 12px; padding: 0 0.75rem;
|
||||
background: #fff; color: #94a3b8; min-width: 220px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.75rem;
|
||||
background: #fff; color: var(--muted); min-width: 200px; max-width: 360px;
|
||||
height: var(--control-h); min-height: var(--control-h); box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.search input, select, textarea, .form input, .form select, .form textarea {
|
||||
border: 0; background: none; font: inherit; min-width: 0; width: 100%;
|
||||
padding: 0.65rem 0;
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
select, .form input, .form select, .form textarea {
|
||||
border: 1px solid #e2e8f0; border-radius: 12px; padding: 0.65rem 0.8rem; background: #fff;
|
||||
.search input {
|
||||
border: 0; outline: none; background: none; font: inherit; font-size: var(--fs-sm);
|
||||
min-width: 0; width: 100%; color: var(--ink); padding: 0;
|
||||
}
|
||||
select, textarea, .form input, .form select, .form textarea {
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.75rem; background: #fff;
|
||||
min-height: var(--control-h); height: var(--control-h); font: inherit; font-size: var(--fs-sm);
|
||||
box-sizing: border-box; color: var(--ink);
|
||||
}
|
||||
textarea, .form textarea {
|
||||
height: auto; min-height: 80px; padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.table-wrap { overflow: auto; border: 1px solid #eef2f7; border-radius: 14px; }
|
||||
.table-wrap { overflow: auto; border: 1px solid #eef2f7; border-radius: var(--radius); }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 860px; }
|
||||
th, td { padding: 0.8rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; font-size: 0.9rem; }
|
||||
th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
@@ -1251,7 +1269,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.who { display: flex; align-items: center; gap: 0.55rem; }
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: 999px; background: #ccfbf1; color: #0f766e;
|
||||
display: grid; place-items: center; font-weight: 800; font-size: 0.75rem; flex-shrink: 0;
|
||||
display: grid; place-items: center; font-weight: 600; font-size: 0.75rem; flex-shrink: 0;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
@@ -1261,7 +1279,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.badge.silver { background: #e2e8f0; color: #475569; }
|
||||
.badge.bronze { background: #ffedd5; color: #c2410c; }
|
||||
.badge.custom { background: #ccfbf1; color: #0f766e; }
|
||||
.pts { font-weight: 800; }
|
||||
.pts { font-weight: 600; }
|
||||
.ops { position: relative; display: inline-flex; gap: 0.3rem; align-items: center; }
|
||||
.act {
|
||||
width: 30px; height: 30px; border: 0; border-radius: 999px; display: grid; place-items: center; cursor: pointer;
|
||||
@@ -1271,7 +1289,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.act.more { background: #f1f5f9; color: #64748b; }
|
||||
.menu {
|
||||
position: absolute; inset-inline-end: 0; top: 34px; z-index: 5;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
display: grid; min-width: 140px; overflow: hidden;
|
||||
}
|
||||
@@ -1323,7 +1341,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
}
|
||||
.mob-pts em {
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -1332,7 +1350,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
|
||||
.reward-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; }
|
||||
.reward-card {
|
||||
border: 1px solid #eef2f7; border-radius: 16px; padding: 0.95rem; display: grid; gap: 0.3rem;
|
||||
border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.95rem; display: grid; gap: 0.3rem;
|
||||
}
|
||||
.reward-card.off { opacity: 0.55; }
|
||||
.reward-card p, .reward-card small { margin: 0; color: var(--muted); font-size: 0.84rem; }
|
||||
@@ -1341,15 +1359,15 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: grid; place-items: center; padding: 1rem;
|
||||
display: grid; place-items: center; padding: 16px;
|
||||
}
|
||||
.sheet {
|
||||
width: min(460px, 100%);
|
||||
background: #fff; border-radius: 18px; padding: 1rem 1.05rem 1.1rem;
|
||||
display: grid; gap: 0.8rem;
|
||||
background: #fff; border-radius: var(--radius, 10px); padding: 16px;
|
||||
display: grid; gap: 16px;
|
||||
}
|
||||
.sheet header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.sheet h3 { margin: 0; font-size: 1.05rem; }
|
||||
.sheet h3 { margin: 0; font-size: 16px; font-weight: 500; color: var(--ink); line-height: 1.4; }
|
||||
.form, .ledger { display: grid; gap: 0.7rem; }
|
||||
.form label { display: grid; gap: 0.3rem; font-size: 0.88rem; font-weight: 700; color: #334155; }
|
||||
.check-row { display: flex; align-items: center; gap: 0.45rem; font-weight: 700; }
|
||||
@@ -1359,7 +1377,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.form footer { display: flex; justify-content: flex-end; gap: 0.45rem; }
|
||||
.signs { display: flex; gap: 0.4rem; }
|
||||
.signs button {
|
||||
flex: 1; border: 1px solid #e2e8f0; background: #fff; border-radius: 10px;
|
||||
flex: 1; border: 1px solid #e2e8f0; background: #fff; border-radius: var(--radius);
|
||||
min-height: 38px; font: inherit; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.signs button.on { background: #ccfbf1; border-color: #99f6e4; color: var(--teal-deep); }
|
||||
|
||||
@@ -1214,17 +1214,18 @@ onBeforeUnmount(() => {
|
||||
--note-default: #ffffff;
|
||||
--teal: #0d9488;
|
||||
--teal-soft: #ccfbf1;
|
||||
--radius: 15px;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 18px rgba(15, 23, 42, 0.07);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem 0 4rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
.lead {
|
||||
@@ -1236,7 +1237,7 @@ onBeforeUnmount(() => {
|
||||
.banner-error {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
@@ -1339,7 +1340,7 @@ onBeforeUnmount(() => {
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -1356,7 +1357,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.sort-select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
@@ -1367,7 +1368,7 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
@@ -1420,7 +1421,7 @@ onBeforeUnmount(() => {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
@@ -1518,7 +1519,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 5;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 140px;
|
||||
padding: 0.25rem;
|
||||
@@ -1530,7 +1531,7 @@ onBeforeUnmount(() => {
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
@@ -1591,7 +1592,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.card-thumb {
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
max-height: 72px;
|
||||
@@ -1609,7 +1610,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
@@ -1750,7 +1751,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 1;
|
||||
width: min(520px, 100%);
|
||||
max-height: min(92vh, 840px);
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
|
||||
display: flex;
|
||||
@@ -1774,25 +1775,28 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.panel-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 16px;
|
||||
}
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
@@ -1808,7 +1812,7 @@ onBeforeUnmount(() => {
|
||||
.body-canvas {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
@@ -1897,7 +1901,7 @@ onBeforeUnmount(() => {
|
||||
gap: 0.3rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
@@ -1920,7 +1924,7 @@ onBeforeUnmount(() => {
|
||||
.field select,
|
||||
.field input[type='text'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
color: #0f172a;
|
||||
@@ -1937,7 +1941,7 @@ onBeforeUnmount(() => {
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #f0fdfa;
|
||||
border: 1px solid #99f6e4;
|
||||
cursor: pointer;
|
||||
@@ -1966,7 +1970,7 @@ onBeforeUnmount(() => {
|
||||
.check-row input[type='text'] {
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.linkish {
|
||||
@@ -1990,7 +1994,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
@@ -2007,7 +2011,7 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius, 10px);
|
||||
padding: 0.55rem 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -2021,7 +2025,7 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius, 10px);
|
||||
padding: 0.55rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -2101,7 +2105,7 @@ onBeforeUnmount(() => {
|
||||
.sheet {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 18px 18px 0 0;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
@@ -2174,7 +2178,7 @@ onBeforeUnmount(() => {
|
||||
.editor-panel {
|
||||
width: min(100%, 520px);
|
||||
max-height: 94vh;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.panel-root {
|
||||
padding: 0.5rem;
|
||||
|
||||
@@ -10,6 +10,15 @@ import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -157,371 +166,210 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="desktop-head">
|
||||
<div class="title-block">
|
||||
<span class="doc-ico"><UiIcon name="user" :size="22" /></span>
|
||||
<div>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<p class="crumb">
|
||||
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desk-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
|
||||
<!-- Desktop Header -->
|
||||
<UiPageHeader
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
:back-to="backTo"
|
||||
>
|
||||
<template #lead>
|
||||
<span class="crumb">
|
||||
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" :disabled="saving" @click="remove">
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header v-if="!embedded" class="mobile-head">
|
||||
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<form v-else class="form" @submit.prevent="submit">
|
||||
<section class="card">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="firstName">نام</label>
|
||||
<input id="firstName" v-model="firstName" required />
|
||||
<form v-else class="crm-form" @submit.prevent="submit">
|
||||
<UiCard>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام" required for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" required />
|
||||
</UiField>
|
||||
|
||||
<UiField label="نام خانوادگی" required for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" required />
|
||||
</UiField>
|
||||
|
||||
<UiField label="موبایل" for-id="mobile">
|
||||
<UiInput id="mobile" v-model="mobile" type="tel" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="تلفن" for-id="phone">
|
||||
<UiInput id="phone" v-model="phone" type="tel" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="ایمیل" for-id="email">
|
||||
<UiInput id="email" v-model="email" type="email" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="سمت" for-id="position">
|
||||
<UiInput id="position" v-model="position" />
|
||||
</UiField>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="وضعیت مشتری">
|
||||
<div class="moods">
|
||||
<button
|
||||
v-for="item in PERSON_MOODS"
|
||||
:key="item"
|
||||
class="mood-btn"
|
||||
type="button"
|
||||
:class="{ on: mood === item }"
|
||||
@click="mood = item"
|
||||
>
|
||||
<CustomerMoodFace :mood="item" :size="40" />
|
||||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="lastName">نام خانوادگی</label>
|
||||
<input id="lastName" v-model="lastName" required />
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="شرکت" for-id="companyId">
|
||||
<UiSelect id="companyId" v-model="companyId">
|
||||
<option value="">بدون شرکت</option>
|
||||
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mobile">موبایل</label>
|
||||
<input id="mobile" v-model="mobile" inputmode="tel" />
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="یادداشت" for-id="notes">
|
||||
<UiTextarea id="notes" v-model="notes" :rows="3" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">تلفن</label>
|
||||
<input id="phone" v-model="phone" inputmode="tel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">ایمیل</label>
|
||||
<input id="email" v-model="email" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="position">سمت</label>
|
||||
<input id="position" v-model="position" />
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>وضعیت مشتری</label>
|
||||
<div class="moods">
|
||||
<button
|
||||
v-for="item in PERSON_MOODS"
|
||||
:key="item"
|
||||
class="mood"
|
||||
type="button"
|
||||
:class="{ on: mood === item }"
|
||||
@click="mood = item"
|
||||
>
|
||||
<CustomerMoodFace :mood="item" :size="44" />
|
||||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
|
||||
<div class="loyalty-info">
|
||||
<span class="muted">باشگاه مشتریان</span>
|
||||
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label for="companyId">شرکت</label>
|
||||
<select id="companyId" v-model="companyId">
|
||||
<option value="">بدون شرکت</option>
|
||||
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label for="notes">یادداشت</label>
|
||||
<textarea id="notes" v-model="notes" rows="4" />
|
||||
</div>
|
||||
<div v-if="!isNew" class="field wide loyalty-box">
|
||||
<p class="muted">باشگاه مشتریان</p>
|
||||
<p>
|
||||
{{ loyaltyPoints }} امتیاز
|
||||
<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span>
|
||||
</p>
|
||||
<RouterLink :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
|
||||
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UiCard>
|
||||
|
||||
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||||
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="crm-field-error">{{ error }}</p>
|
||||
|
||||
<div class="mobile-actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
<div class="mobile-actions mob-only">
|
||||
<UiButton variant="primary" block type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" :disabled="saving" @click="remove">
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
|
||||
حذف
|
||||
</button>
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-width: 820px;
|
||||
min-width: 0;
|
||||
padding-bottom: 1.5rem;
|
||||
.crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
max-width: none;
|
||||
padding-bottom: 0;
|
||||
.crumb a {
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.desktop-head {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
margin: 0.2rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mobile-head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-head h1 {
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 0.95rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.moods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mood {
|
||||
.mood-btn {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mood.on {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loyalty-box {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.loyalty-box a {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
.mood-btn.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
.loyalty-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
.loyalty-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loyalty-link {
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.desktop-head {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-head,
|
||||
.mobile-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
@media (min-width: 901px) {
|
||||
.mob-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -928,9 +928,8 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.page {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6.4rem;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -971,7 +970,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
@@ -1030,7 +1029,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
|
||||
.hero {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1054,7 +1053,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
}
|
||||
@@ -1103,7 +1102,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: color-mix(in srgb, #0f766e 12%, transparent);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.12rem 0.5rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -1215,7 +1214,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
@@ -1284,7 +1283,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem 0.95rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
min-width: 0;
|
||||
@@ -1339,7 +1338,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
|
||||
min-width: 8.5rem;
|
||||
z-index: 4;
|
||||
@@ -1351,7 +1350,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
background: none;
|
||||
text-align: start;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1434,7 +1433,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
color: #b45309;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
padding: 0.18rem 0.55rem;
|
||||
}
|
||||
|
||||
@@ -1478,10 +1477,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
border: 1.5px solid #14b8a6;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.62rem 0.8rem;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1506,7 +1505,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1530,7 +1529,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.file-ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fee2e2;
|
||||
@@ -1546,7 +1545,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 0.95rem;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
@@ -1567,7 +1566,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.info-panel dl {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
@@ -1597,7 +1596,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.smart {
|
||||
background: linear-gradient(135deg, #ecfeff, #f5f3ff);
|
||||
border: 1px solid #e0e7ff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -1628,7 +1627,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.smart-cta {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
@@ -1699,7 +1698,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
|
||||
.fab-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #0f766e;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -461,7 +461,7 @@ h3 {
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #d7e0db;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
@@ -475,7 +475,7 @@ label {
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid #cfd9d3;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
@@ -484,7 +484,7 @@ textarea {
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 1rem;
|
||||
background: #0f6b54;
|
||||
color: #fff;
|
||||
@@ -531,7 +531,7 @@ textarea {
|
||||
|
||||
.msg {
|
||||
border: 1px solid #e0e8e3;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ textarea {
|
||||
.score {
|
||||
width: 2.3rem;
|
||||
height: 2.3rem;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #cfd9d3;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSegmented,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
CreateProductRequest,
|
||||
@@ -45,6 +56,11 @@ const error = ref('');
|
||||
const enabledCurrencies = ref<string[]>(['IRR']);
|
||||
|
||||
const kind = ref<'product' | 'service'>('product');
|
||||
const kindOptions = [
|
||||
{ value: 'product' as const, label: 'کالا / محصول' },
|
||||
{ value: 'service' as const, label: 'خدمت' },
|
||||
];
|
||||
|
||||
const name = ref('');
|
||||
const category = ref('');
|
||||
const sku = ref('');
|
||||
@@ -60,7 +76,7 @@ const country = ref('');
|
||||
const tagInput = ref('');
|
||||
const tags = ref<string[]>([]);
|
||||
|
||||
const title = computed(() => (isEdit.value ? 'ویرایش محصول' : 'افزودن محصول'));
|
||||
const title = computed(() => (isEdit.value ? 'ویرایش محصول' : 'افزودن محصول جدید'));
|
||||
const submitLabel = computed(() =>
|
||||
saving.value
|
||||
? 'در حال ثبت…'
|
||||
@@ -196,515 +212,335 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" :disabled="saving" @click="cancel">انصراف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="page-head">
|
||||
<button type="button" class="back mob-only" aria-label="بازگشت" @click="cancel">
|
||||
<UiIcon name="chevron" :size="20" />
|
||||
</button>
|
||||
<div class="title-row">
|
||||
<span class="title-ico" aria-hidden="true">
|
||||
<UiIcon name="box" :size="22" />
|
||||
</span>
|
||||
<div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="lead">اطلاعات محصول را وارد کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="crm-page product-form-page" :class="{ embedded }" dir="rtl">
|
||||
<UiPageHeader
|
||||
v-if="!embedded"
|
||||
:title="title"
|
||||
description="تعریف و ویرایش کالا و خدمات، قیمتگذاری و مدیریت موجودی انبار"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<RouterLink :to="{ name: 'products' }" class="breadcrumb-link">
|
||||
محصولات
|
||||
</RouterLink>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="submit">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
{{ submitLabel }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<form v-else class="form" @submit.prevent="submit">
|
||||
<div class="kind-seg mob-only">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: kind === 'product' }"
|
||||
@click="kind = 'product'"
|
||||
>
|
||||
<UiIcon name="box" :size="16" />
|
||||
محصول
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: kind === 'service' }"
|
||||
@click="kind = 'service'"
|
||||
>
|
||||
<UiIcon name="briefcase" :size="16" />
|
||||
خدمت
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card main">
|
||||
<h2>اطلاعات اصلی</h2>
|
||||
|
||||
<label class="field kind-desk">
|
||||
<span>نوع</span>
|
||||
<div class="kind-seg">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: kind === 'product' }"
|
||||
@click="kind = 'product'"
|
||||
>
|
||||
<UiIcon name="box" :size="16" />
|
||||
محصول
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: kind === 'service' }"
|
||||
@click="kind = 'service'"
|
||||
>
|
||||
<UiIcon name="briefcase" :size="16" />
|
||||
خدمت
|
||||
</button>
|
||||
<form class="crm-form product-form" @submit.prevent="submit">
|
||||
<div class="form-layout-grid">
|
||||
<div class="main-column">
|
||||
<UiCard title="مشخصات اصلی کالا / خدمت" class="section-card">
|
||||
<div class="kind-selection-row">
|
||||
<span class="kind-label">ماهیت آیتم:</span>
|
||||
<UiSegmented v-model="kind" :options="kindOptions" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>نام محصول <em>*</em></span>
|
||||
<div class="control">
|
||||
<UiIcon name="box" :size="18" />
|
||||
<input v-model="name" required placeholder="مثلاً انگشتر طلا مدل ونوس" />
|
||||
</div>
|
||||
</label>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام کالا یا خدمت" required class="col-span-2">
|
||||
<UiInput
|
||||
v-model="name"
|
||||
required
|
||||
placeholder="مثلاً لپتاپ لنوو مدل ThinkPad یا پشتیبانی سالانه نرمافزار"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<label class="field">
|
||||
<span>دستهبندی <em>*</em></span>
|
||||
<div class="control">
|
||||
<UiIcon name="layers" :size="18" />
|
||||
<select v-model="category" required>
|
||||
<option disabled value="">دستهبندی را انتخاب کنید</option>
|
||||
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<UiField label="دستهبندی" required>
|
||||
<UiSelect v-model="category" required>
|
||||
<option disabled value="">انتخاب دستهبندی</option>
|
||||
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<label class="field">
|
||||
<span>کد / SKU <em>*</em></span>
|
||||
<div class="control">
|
||||
<UiIcon name="flag" :size="18" />
|
||||
<input v-model="sku" required placeholder="MNT-001" dir="ltr" />
|
||||
</div>
|
||||
</label>
|
||||
<UiField label="کد کالا / شناسه انبار (SKU)" required>
|
||||
<UiInput
|
||||
v-model="sku"
|
||||
required
|
||||
placeholder="MNT-001"
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<span>قیمت واحد <em>*</em></span>
|
||||
<div class="control price">
|
||||
<UiIcon name="dollar" :size="18" />
|
||||
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ را وارد کنید" />
|
||||
<span class="suffix">{{ currency === 'IRR' || currency === 'IRT' ? 'تومان' : currency }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>ارز</span>
|
||||
<div class="control">
|
||||
<select v-model="currency">
|
||||
<UiField label="قیمت پایه / واحد" required>
|
||||
<div class="price-input-wrapper">
|
||||
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ واحد" />
|
||||
<span class="currency-unit-label">
|
||||
{{ currency === 'IRR' || currency === 'IRT' ? 'تومان' : currency }}
|
||||
</span>
|
||||
</div>
|
||||
</UiField>
|
||||
|
||||
<UiField label="ارز قیمتگذاری">
|
||||
<UiSelect v-model="currency">
|
||||
<option v-for="code in enabledCurrencies" :key="code" :value="code">
|
||||
{{ code }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<span>واحد سنجش <em>*</em></span>
|
||||
<div class="control">
|
||||
<UiIcon name="layers" :size="18" />
|
||||
<select v-model="unit" required>
|
||||
<UiField label="واحد سنجش" required>
|
||||
<UiSelect v-model="unit" required>
|
||||
<option v-for="u in UNITS" :key="u" :value="u">{{ u }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>موجودی</span>
|
||||
<div class="control">
|
||||
<UiIcon name="bag" :size="18" />
|
||||
<input v-model="stockQty" type="number" min="0" placeholder="مثلاً ۱۲۰" dir="ltr" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div class="status-row">
|
||||
<span>وضعیت محصول</span>
|
||||
<label class="switch">
|
||||
<input v-model="isActive" type="checkbox" />
|
||||
<span class="track" />
|
||||
<span class="switch-text">این محصول فعال باشد</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="side">
|
||||
<section class="card">
|
||||
<h2>توضیحات</h2>
|
||||
<label class="field">
|
||||
<div class="control area">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="5"
|
||||
placeholder="توضیحات محصول را وارد کنید..."
|
||||
<UiField v-if="kind === 'product'" label="موجودی اولیه در انبار">
|
||||
<UiInput
|
||||
v-model="stockQty"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="مثلاً ۱۲۰"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<section class="card desk-only">
|
||||
<h2>اطلاعات تکمیلی</h2>
|
||||
<label class="field">
|
||||
<span>برند</span>
|
||||
<div class="control">
|
||||
<UiIcon name="building" :size="18" />
|
||||
<input v-model="brand" placeholder="برند را وارد کنید" />
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>مدل</span>
|
||||
<div class="control">
|
||||
<UiIcon name="grid" :size="18" />
|
||||
<input v-model="model" placeholder="مدل محصول" />
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>کشور سازنده</span>
|
||||
<div class="control">
|
||||
<UiIcon name="target" :size="18" />
|
||||
<input v-model="country" placeholder="کشور سازنده" />
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>برچسبها</span>
|
||||
<div class="control">
|
||||
<UiIcon name="star" :size="18" />
|
||||
<input
|
||||
<div class="status-toggle-box">
|
||||
<label class="toggle-switch-label">
|
||||
<input v-model="isActive" type="checkbox" />
|
||||
<span class="toggle-text">این آیتم فعال و در فاکتورها قابل انتخاب باشد</span>
|
||||
</label>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="شرح و توضیحات محصول" class="section-card">
|
||||
<UiField label="توضیحات کامل کالا یا شرایط خدمت">
|
||||
<UiTextarea
|
||||
v-model="description"
|
||||
:rows="4"
|
||||
placeholder="مشخصات فنی، گارانتی، اقلام همراه یا شرایط ارائه خدمت..."
|
||||
/>
|
||||
</UiField>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="side-column">
|
||||
<UiCard title="اطلاعات تکمیلی و بازرگانی" class="section-card">
|
||||
<div class="side-form-stack">
|
||||
<UiField label="برند یا تولیدکننده">
|
||||
<UiInput v-model="brand" placeholder="نام برند تجاری" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="مدل یا سری ساخت">
|
||||
<UiInput v-model="model" placeholder="سری و مدل" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="کشور سازنده">
|
||||
<UiInput v-model="country" placeholder="مثلاً ایران، آلمان" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="برچسبها (تگ)" hint="برای ثبت هر برچسب Enter بزنید">
|
||||
<UiInput
|
||||
v-model="tagInput"
|
||||
placeholder="برچسب را وارد کنید و Enter بزنید"
|
||||
placeholder="افزودن برچسب..."
|
||||
@keydown="onTagKey"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="tags.length" class="tags">
|
||||
<button
|
||||
v-for="t in tags"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="tag"
|
||||
@click="removeTag(t)"
|
||||
>
|
||||
{{ t }} ×
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<div v-if="tags.length" class="tags-container">
|
||||
<UiBadge
|
||||
v-for="t in tags"
|
||||
:key="t"
|
||||
variant="neutral"
|
||||
class="tag-badge"
|
||||
>
|
||||
<span>{{ t }}</span>
|
||||
<button type="button" class="tag-remove-btn" @click="removeTag(t)">×</button>
|
||||
</UiBadge>
|
||||
</div>
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
<UiIcon name="check" :size="18" />
|
||||
<div class="form-footer-actions">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
<button class="btn ghost desk-only" type="button" @click="cancel">انصراف</button>
|
||||
</footer>
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 1100px;
|
||||
.product-form-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
.product-form-page.embedded {
|
||||
max-width: none;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
.embedded-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.page-head {
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-brand-600);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.product-form {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.back {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
|
||||
.form-layout-grid {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.title-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
.lead {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
.side {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
.main-column,
|
||||
.side-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field em {
|
||||
color: #dc2626;
|
||||
font-style: normal;
|
||||
}
|
||||
.control {
|
||||
|
||||
.kind-selection-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
padding-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.control input,
|
||||
.control select,
|
||||
.control textarea {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
|
||||
.kind-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.control.area {
|
||||
align-items: flex-start;
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.control.price {
|
||||
padding-inline-end: 0.35rem;
|
||||
|
||||
.price-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.control.price :deep(input) {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
|
||||
.price-input-wrapper :deep(input) {
|
||||
flex: 1;
|
||||
}
|
||||
.suffix {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
|
||||
.currency-unit-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 0.25rem 0.45rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.row-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
|
||||
.status-toggle-box {
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.kind-seg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.kind-seg button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.6rem;
|
||||
|
||||
.toggle-switch-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
.kind-seg button.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
.toggle-text {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.status-row {
|
||||
|
||||
.side-form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.switch {
|
||||
|
||||
.tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.tag-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.tag-remove-btn:hover {
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
.track {
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
position: relative;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
.track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
.switch input:checked + .track {
|
||||
background: var(--accent);
|
||||
}
|
||||
.switch input:checked + .track::after {
|
||||
right: 21px;
|
||||
}
|
||||
.tags {
|
||||
|
||||
.form-footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.tag {
|
||||
border: 0;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
min-width: 160px;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.mob-only {
|
||||
display: none;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
.form-layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.desk-only {
|
||||
display: none;
|
||||
}
|
||||
.mob-only {
|
||||
display: grid;
|
||||
}
|
||||
.kind-desk {
|
||||
display: none;
|
||||
}
|
||||
.back.mob-only {
|
||||
display: grid;
|
||||
}
|
||||
.actions .btn.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 901px) {
|
||||
.mob-only {
|
||||
display: none !important;
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,16 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiEmptyState,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ProductFormView from '@/views/ProductFormView.vue';
|
||||
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
||||
@@ -73,19 +81,10 @@ const filtered = computed(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
const pageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)),
|
||||
);
|
||||
const pageItems = computed(() => {
|
||||
const start = (page.value - 1) * PAGE_SIZE;
|
||||
return filtered.value.slice(start, start + PAGE_SIZE);
|
||||
});
|
||||
const pageFrom = computed(() =>
|
||||
filtered.value.length ? (page.value - 1) * PAGE_SIZE + 1 : 0,
|
||||
);
|
||||
const pageTo = computed(() =>
|
||||
Math.min(page.value * PAGE_SIZE, filtered.value.length),
|
||||
);
|
||||
const allPageSelected = computed(
|
||||
() =>
|
||||
pageItems.value.length > 0 &&
|
||||
@@ -262,26 +261,18 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" @click="closeMenus">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<div class="title-row">
|
||||
<span class="title-ico" aria-hidden="true">
|
||||
<UiIcon name="box" :size="22" />
|
||||
</span>
|
||||
<div>
|
||||
<h1>محصولات</h1>
|
||||
<p class="lead">مدیریت محصولات، قیمتها و موجودی</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions" @click.stop>
|
||||
<button class="ghost-btn" type="button" :disabled="busy" @click="exportExcel">
|
||||
<section class="crm-page" @click="closeMenus">
|
||||
<UiPageHeader
|
||||
title="محصولات"
|
||||
lead="مدیریت محصولات، قیمتها و موجودی"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="ghost-btn" type="button" :disabled="busy" @click="pickImportFile">
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -289,19 +280,19 @@ onMounted(load);
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<UiButton type="button" @click="productModal = true">
|
||||
<UiButton variant="primary" @click="productModal = true">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
محصول جدید
|
||||
</UiButton>
|
||||
<div class="more-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
<div class="more-wrap" @click.stop>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label="عملیات بیشتر"
|
||||
@click="moreOpen = !moreOpen"
|
||||
>
|
||||
<UiIcon name="dots" :size="18" />
|
||||
</button>
|
||||
</UiButton>
|
||||
<div v-if="moreOpen" class="menu">
|
||||
<button type="button" :disabled="!selected.size" @click="removeSelected">
|
||||
حذف انتخابشدهها
|
||||
@@ -309,32 +300,35 @@ onMounted(load);
|
||||
<button type="button" @click="load">بروزرسانی لیست</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="toolbar" @click.stop>
|
||||
<label class="search">
|
||||
<UiIcon name="search" :size="18" />
|
||||
<input
|
||||
<div class="crm-toolbar" @click.stop>
|
||||
<div class="search-box">
|
||||
<UiInput
|
||||
v-model="q"
|
||||
type="search"
|
||||
placeholder="جستجو در نام، کد یا توضیحات..."
|
||||
/>
|
||||
</label>
|
||||
<select v-model="category" class="filter-select">
|
||||
>
|
||||
<template #prefix>
|
||||
<UiIcon name="search" :size="16" />
|
||||
</template>
|
||||
</UiInput>
|
||||
</div>
|
||||
<UiSelect v-model="category">
|
||||
<option value="">همه دستهبندیها</option>
|
||||
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="statusFilter" class="filter-select">
|
||||
</UiSelect>
|
||||
<UiSelect v-model="statusFilter">
|
||||
<option value="all">همه وضعیتها</option>
|
||||
<option value="active">فعال</option>
|
||||
<option value="inactive">غیرفعال</option>
|
||||
</select>
|
||||
</UiSelect>
|
||||
<div class="sort-wrap">
|
||||
<button type="button" class="ghost-btn" @click="sortOpen = !sortOpen">
|
||||
<UiButton variant="outline" @click="sortOpen = !sortOpen">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
مرتبسازی
|
||||
</button>
|
||||
</UiButton>
|
||||
<div v-if="sortOpen" class="menu">
|
||||
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'; sortOpen = false">
|
||||
نام
|
||||
@@ -356,7 +350,7 @@ onMounted(load);
|
||||
<template v-else>
|
||||
<!-- Desktop table -->
|
||||
<div class="table-wrap desk">
|
||||
<table>
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check">
|
||||
@@ -398,7 +392,7 @@ onMounted(load);
|
||||
</td>
|
||||
<td class="sku" dir="ltr">{{ p.sku || '—' }}</td>
|
||||
<td>
|
||||
<span v-if="p.category" class="pill">{{ p.category }}</span>
|
||||
<UiBadge v-if="p.category" variant="neutral" size="sm">{{ p.category }}</UiBadge>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
<td class="price">
|
||||
@@ -407,10 +401,9 @@ onMounted(load);
|
||||
</td>
|
||||
<td class="stock">{{ p.stockQty != null ? toFaDigits(p.stockQty) : '—' }}</td>
|
||||
<td>
|
||||
<span class="status" :class="p.isActive ? 'on' : 'off'">
|
||||
<i />
|
||||
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td class="when">
|
||||
<span>{{ formatDate(p.createdAt) }}</span>
|
||||
@@ -438,7 +431,12 @@ onMounted(load);
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!pageItems.length" class="empty">محصولی یافت نشد.</p>
|
||||
<UiEmptyState
|
||||
v-if="!pageItems.length"
|
||||
icon="box"
|
||||
title="محصولی یافت نشد"
|
||||
description="هیچ محصولی با معیارهای جستجو مطابقت ندارد."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
@@ -450,7 +448,7 @@ onMounted(load);
|
||||
<div class="mob-body">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="sku" dir="ltr">{{ p.sku || '—' }}</span>
|
||||
<span v-if="p.category" class="pill soft">{{ p.category }}</span>
|
||||
<UiBadge v-if="p.category" variant="neutral" size="sm">{{ p.category }}</UiBadge>
|
||||
<span class="mob-price">
|
||||
{{ formatPrice(p.unitPrice, p.currency) }}
|
||||
{{ currencyLabel(p.currency) }}
|
||||
@@ -474,48 +472,25 @@ onMounted(load);
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status" :class="p.isActive ? 'on' : 'off'">
|
||||
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</UiBadge>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!pageItems.length" class="empty">محصولی یافت نشد.</p>
|
||||
<UiEmptyState
|
||||
v-if="!pageItems.length"
|
||||
icon="box"
|
||||
title="محصولی یافت نشد"
|
||||
description="هیچ محصولی با معیارهای جستجو مطابقت ندارد."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer v-if="filtered.length" class="pager">
|
||||
<p class="muted">
|
||||
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
|
||||
{{ toFaDigits(filtered.length) }} مورد
|
||||
</p>
|
||||
<div class="pages">
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="page -= 1"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
v-for="n in pageCount"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:class="{ on: page === n }"
|
||||
@click="page = n"
|
||||
>
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:disabled="page >= pageCount"
|
||||
@click="page += 1"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
<UiPagination
|
||||
v-if="filtered.length"
|
||||
v-model:page="page"
|
||||
:total-items="filtered.length"
|
||||
:page-size="PAGE_SIZE"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<UiModal :open="productModal" title="محصول جدید" @close="productModal = false">
|
||||
@@ -530,126 +505,15 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
.more-wrap,
|
||||
.sort-wrap,
|
||||
.row-more {
|
||||
position: relative;
|
||||
padding-bottom: 4.5rem;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.title-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
.lead {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.icon-btn,
|
||||
.ghost-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
}
|
||||
.ghost-btn {
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search input {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
color: var(--text);
|
||||
}
|
||||
.filter-select {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--text);
|
||||
min-width: 140px;
|
||||
|
||||
.search-box {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.more-wrap,
|
||||
.sort-wrap,
|
||||
@@ -664,7 +528,7 @@ h1 {
|
||||
min-width: 160px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
@@ -678,7 +542,7 @@ h1 {
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -693,7 +557,7 @@ h1 {
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
@@ -729,7 +593,7 @@ tr:last-child td {
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
@@ -817,7 +681,7 @@ tr:last-child td {
|
||||
align-items: start;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
.mob-body {
|
||||
|
||||
@@ -767,11 +767,10 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.profile {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 5.6rem;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
overflow: visible;
|
||||
@@ -806,7 +805,7 @@ watch(
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
color: #0f766e;
|
||||
font-size: 1.15rem;
|
||||
@@ -817,7 +816,7 @@ watch(
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #334155;
|
||||
@@ -842,7 +841,7 @@ watch(
|
||||
min-width: 12.75rem;
|
||||
width: max-content;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
@@ -862,7 +861,7 @@ watch(
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.68rem 0.8rem;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
@@ -1000,7 +999,7 @@ h1 {
|
||||
.qa-card {
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 0.4rem 0.75rem;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
@@ -1015,7 +1014,7 @@ h1 {
|
||||
.qa-ico {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
@@ -1028,7 +1027,7 @@ h1 {
|
||||
.smart {
|
||||
background: linear-gradient(135deg, #ecfeff, #f5f3ff);
|
||||
border: 1px solid #e0e7ff;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -1060,7 +1059,7 @@ h1 {
|
||||
.smart-cta {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
@@ -1117,7 +1116,7 @@ h1 {
|
||||
|
||||
.kpi {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.65rem 0.75rem;
|
||||
min-width: 0;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
||||
@@ -1151,7 +1150,7 @@ h1 {
|
||||
.kpi-ico {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.15rem;
|
||||
@@ -1272,7 +1271,7 @@ h1 {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 0.95rem;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
@@ -1293,8 +1292,8 @@ h1 {
|
||||
.info-form {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 0.4rem 1rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 1rem;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
@@ -1374,7 +1373,7 @@ h1 {
|
||||
bottom: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
@@ -1477,7 +1476,7 @@ h1 {
|
||||
.qa-card {
|
||||
padding: 0.7rem 0.2rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.qa-ico {
|
||||
|
||||
@@ -206,7 +206,7 @@ onMounted(load);
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-width: 640px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -237,7 +237,7 @@ onMounted(load);
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecfdf5;
|
||||
@@ -296,7 +296,7 @@ h1 {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ function backToForm() {
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -390,7 +390,7 @@ function backToForm() {
|
||||
|
||||
.mobile-brand strong {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
@@ -413,7 +413,7 @@ function backToForm() {
|
||||
.title-ico {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, #0d9488 0 18%, transparent 19%),
|
||||
@@ -428,7 +428,7 @@ function backToForm() {
|
||||
inset-inline-end: 6px;
|
||||
bottom: 4px;
|
||||
color: #0d9488;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ h1 {
|
||||
.input-wrap input,
|
||||
.form > .grid-2 .field > input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
@@ -500,7 +500,7 @@ h1 {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
background: #64748b;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ico.user {
|
||||
@@ -514,7 +514,7 @@ h1 {
|
||||
background: linear-gradient(#64748b, #64748b);
|
||||
mask: linear-gradient(#000 0 0);
|
||||
opacity: 0.4;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ico.lock,
|
||||
@@ -547,7 +547,7 @@ h1 {
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.login-cta {
|
||||
@@ -565,7 +565,7 @@ h1 {
|
||||
justify-content: center;
|
||||
min-width: min(100%, 280px);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
@@ -584,7 +584,7 @@ h1 {
|
||||
.dev-hint {
|
||||
margin: 0.75rem 0 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: #0f766e;
|
||||
font-size: 0.88rem;
|
||||
|
||||
+698
-709
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,12 @@ import {
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiChart,
|
||||
type UiChartOption,
|
||||
} from '@/components/ui';
|
||||
import type {
|
||||
CreateSalesGoalRequest,
|
||||
OrgUserDto,
|
||||
@@ -29,7 +35,6 @@ type StatusFilter = 'all' | SalesGoalStatus;
|
||||
type MobTab = 'goals' | 'forecast';
|
||||
type MobScreen = 'list' | 'form';
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50];
|
||||
const GOAL_TYPES: { value: SalesGoalType; label: string; icon: 'target' | 'users' | 'chart' | 'briefcase' | 'check' | 'star' }[] = [
|
||||
{ value: 'sales', label: 'فروش', icon: 'target' },
|
||||
{ value: 'customer', label: 'مشتری', icon: 'users' },
|
||||
@@ -103,9 +108,6 @@ const pageItems = computed(() => {
|
||||
const pageFrom = computed(() =>
|
||||
filteredGoals.value.length ? (page.value - 1) * pageSize.value + 1 : 0,
|
||||
);
|
||||
const pageTo = computed(() =>
|
||||
Math.min(page.value * pageSize.value, filteredGoals.value.length),
|
||||
);
|
||||
|
||||
const formTitle = computed(() =>
|
||||
editingId.value ? 'ویرایش هدف' : 'افزودن هدف جدید',
|
||||
@@ -133,6 +135,51 @@ function formatMoney(amount: number) {
|
||||
return formatMoneyLib(amount, currency.value);
|
||||
}
|
||||
|
||||
const forecastChartOption = computed<UiChartOption>(() => {
|
||||
const f = forecast.value;
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
if (!item) return '';
|
||||
return `${item.name}: <strong>${formatMoney(item.value)}</strong>`;
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '8%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['هدف دوره', 'تحققیافته', 'کاریز باز', 'پیشبینی'],
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontFamily: 'IRANSans',
|
||||
formatter: (v: number) => toFaDigits(Math.round(v).toLocaleString('en-US')),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'مبلغ',
|
||||
type: 'bar',
|
||||
barWidth: '40%',
|
||||
data: [
|
||||
{ value: f?.targetAmount || 0, itemStyle: { color: '#3b82f6', borderRadius: [6, 6, 0, 0] } },
|
||||
{ value: f?.wonAmount || 0, itemStyle: { color: '#10b981', borderRadius: [6, 6, 0, 0] } },
|
||||
{ value: f?.openPipelineAmount || 0, itemStyle: { color: '#f59e0b', borderRadius: [6, 6, 0, 0] } },
|
||||
{ value: f?.forecastAmount || 0, itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function formatValue(amount: number, unit: 'money' | 'count', type?: string) {
|
||||
if (unit === 'count' || type === 'customer' || type === 'deal' || type === 'activity') {
|
||||
return `${toFaDigits(Math.round(amount))} ${type === 'customer' ? 'مشتری' : type === 'deal' ? 'معامله' : type === 'activity' ? 'فعالیت' : 'مورد'}`;
|
||||
@@ -418,15 +465,10 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="page-head">
|
||||
<div class="title-row">
|
||||
<span class="title-ico"><UiIcon name="target" :size="22" /></span>
|
||||
<div>
|
||||
<h1>هدف و پیشبینی</h1>
|
||||
<p class="lead">اهداف ماهانه را تنظیم و پیشبینیهای خود را مشاهده نمایید</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<UiPageHeader
|
||||
title="هدف و پیشبینی"
|
||||
lead="اهداف ماهانه را تنظیم و پیشبینیهای خود را مشاهده نمایید"
|
||||
/>
|
||||
|
||||
<div class="mob-tabs mob-only">
|
||||
<button type="button" :class="{ on: mobTab === 'goals' }" @click="mobTab = 'goals'">لیست اهداف</button>
|
||||
@@ -666,37 +708,18 @@ onMounted(() => {
|
||||
<div class="sum-row payable"><span>پیشبینی درآمد</span><strong>{{ formatMoney(forecast?.forecastAmount || 0) }}</strong></div>
|
||||
<div class="sum-row"><span>مشتریان جدید</span><strong>{{ toFaDigits(forecast?.newCustomersCount || 0) }}</strong></div>
|
||||
<div class="sum-row"><span>معاملات جدید</span><strong>{{ toFaDigits(forecast?.newDealsCount || 0) }}</strong></div>
|
||||
<UiChart :option="forecastChartOption" height="220px" style="margin-top: 1rem;" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer v-if="filteredGoals.length && mobTab !== 'forecast'" class="pager desk-only">
|
||||
<div class="pager-meta">
|
||||
<p class="muted">
|
||||
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
|
||||
{{ toFaDigits(filteredGoals.length) }} مورد
|
||||
</p>
|
||||
<label class="page-size">
|
||||
نمایش در هر صفحه:
|
||||
<select v-model.number="pageSize">
|
||||
<option v-for="n in PAGE_SIZES" :key="n" :value="n">{{ toFaDigits(n) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pages">
|
||||
<button type="button" class="page-btn" :disabled="page <= 1" @click="page -= 1">‹</button>
|
||||
<button
|
||||
v-for="n in pageCount"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:class="{ on: page === n }"
|
||||
@click="page = n"
|
||||
>
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
<button type="button" class="page-btn" :disabled="page >= pageCount" @click="page += 1">›</button>
|
||||
</div>
|
||||
</footer>
|
||||
<UiPagination
|
||||
v-if="filteredGoals.length && mobTab !== 'forecast'"
|
||||
:page="page"
|
||||
:total-pages="pageCount"
|
||||
:total-items="filteredGoals.length"
|
||||
:page-size="pageSize"
|
||||
@update:page="page = $event"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
@@ -716,27 +739,28 @@ onMounted(() => {
|
||||
--teal-soft: #f0fdfa;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding-bottom: 5.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-head { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||||
.title-ico, .side-ico, .doc-badge, .kpi-ico {
|
||||
width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center;
|
||||
width: 42px; height: 42px; border-radius: var(--radius); display: grid; place-items: center;
|
||||
background: var(--teal-soft); color: var(--teal); flex-shrink: 0; position: relative;
|
||||
}
|
||||
.doc-badge .plus {
|
||||
position: absolute; bottom: 4px; left: 4px; background: var(--teal); color: #fff;
|
||||
border-radius: 4px; padding: 1px;
|
||||
border-radius: var(--radius); padding: 1px;
|
||||
}
|
||||
h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
|
||||
h1 { margin: 0; font-size: 1.4rem; font-weight: 600; }
|
||||
.lead, .mob-head-copy p { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.88rem; }
|
||||
|
||||
.kpi-row {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem;
|
||||
}
|
||||
.kpi-card {
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 0.9rem;
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.9rem;
|
||||
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
|
||||
box-shadow: 0 8px 20px rgba(15,23,42,.04);
|
||||
}
|
||||
@@ -748,7 +772,7 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
|
||||
content: ''; position: absolute; inset: 7px; border-radius: 50%; background: #fff;
|
||||
}
|
||||
.ring span {
|
||||
position: relative; z-index: 1; font-size: 0.78rem; font-weight: 800; color: var(--teal);
|
||||
position: relative; z-index: 1; font-size: 0.78rem; font-weight: 600; color: var(--teal);
|
||||
}
|
||||
.ring.sm { width: 56px; height: 56px; }
|
||||
.ring.sm::before { inset: 6px; }
|
||||
@@ -762,7 +786,7 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
|
||||
display: grid; grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); gap: 1rem; align-items: start;
|
||||
}
|
||||
.sidebar, .main, .card, .table-wrap, .goal-card, .forecast-summary {
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px;
|
||||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius);
|
||||
box-shadow: 0 8px 20px rgba(15,23,42,.04);
|
||||
}
|
||||
.sidebar { padding: 1rem; display: grid; gap: 0.85rem; position: sticky; top: 0.75rem; }
|
||||
@@ -776,36 +800,42 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
|
||||
.control :deep(.ico) {
|
||||
position: absolute; inset-inline-start: 0.7rem; color: #94a3b8; pointer-events: none; z-index: 1;
|
||||
}
|
||||
.control input, .control select, .control textarea, .search input, .mob-filter select {
|
||||
width: 100%; border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h);
|
||||
padding: 0 0.8rem; background: #fff; font: inherit; color: inherit; box-sizing: border-box;
|
||||
.control input, .control select, .control textarea, .mob-filter select {
|
||||
width: 100%; border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||||
padding: 0 0.8rem; background: #fff; font: inherit; font-size: var(--fs-sm); color: inherit; box-sizing: border-box;
|
||||
}
|
||||
.control input, .control select, .control textarea { padding-inline-start: 2.2rem; }
|
||||
.control.area textarea { min-height: 84px; resize: vertical; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.forecast-block {
|
||||
border-top: 1px dashed #e2e8f0; padding-top: 0.75rem; display: grid; gap: 0.65rem;
|
||||
border-top: 1px dashed var(--line); padding-top: 0.75rem; display: grid; gap: 0.65rem;
|
||||
}
|
||||
.forecast-block h3 {
|
||||
margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.95rem; color: var(--teal);
|
||||
margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.95rem; color: var(--accent);
|
||||
}
|
||||
.btn {
|
||||
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||||
padding: 0 1rem; font-weight: 800; box-sizing: border-box;
|
||||
padding: 0 0.85rem; font-weight: 600; font-size: var(--fs-sm); box-sizing: border-box;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.btn.primary { background: var(--teal); color: #fff; }
|
||||
.btn.ghost { background: #fff; border: 1px solid #e2e8f0; color: #334155; }
|
||||
.btn.primary { background: var(--accent); color: #fff; }
|
||||
.btn.ghost { background: #fff; border: 1px solid var(--line); color: var(--ink-secondary); }
|
||||
.btn.full { width: 100%; }
|
||||
|
||||
.main { padding: 0.9rem; display: grid; gap: 0.85rem; min-width: 0; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.search {
|
||||
flex: 1 1 180px; display: flex; align-items: center; gap: 0.4rem;
|
||||
border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||||
padding: 0 0.7rem; color: #94a3b8; background: #fff; box-sizing: border-box;
|
||||
flex: 1 1 180px; max-width: 380px; display: flex; align-items: center; gap: 0.4rem;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||||
padding: 0 0.75rem; color: var(--muted); background: #fff; box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.search input { border: 0; outline: 0; background: transparent; color: var(--text); }
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.search input { border: 0; outline: 0; background: transparent; color: var(--ink); width: 100%; font-size: var(--fs-sm); padding: 0; }
|
||||
.status-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.status-tabs button {
|
||||
border: 0; background: #f1f5f9; color: #64748b; border-radius: 999px; padding: 0.4rem 0.75rem;
|
||||
@@ -838,18 +868,18 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
.status.needs_attention { background: #ffedd5; color: #c2410c; }
|
||||
.ops { position: relative; white-space: nowrap; }
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff;
|
||||
width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid #e2e8f0; background: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b;
|
||||
}
|
||||
.icon-btn.sm { width: 30px; height: 30px; }
|
||||
.menu {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 120px;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; box-shadow: 0 10px 24px rgba(15,23,42,.12);
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius); box-shadow: 0 10px 24px rgba(15,23,42,.12);
|
||||
padding: 0.3rem; display: grid;
|
||||
}
|
||||
.menu.left { right: 0; left: auto; }
|
||||
.menu button {
|
||||
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: 8px;
|
||||
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: var(--radius);
|
||||
cursor: pointer; font: inherit;
|
||||
}
|
||||
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
|
||||
@@ -858,10 +888,10 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; }
|
||||
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
|
||||
.page-size { display: inline-flex; gap: 0.35rem; align-items: center; color: var(--muted); font-size: 0.85rem; }
|
||||
.page-size select { border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.25rem 0.45rem; }
|
||||
.page-size select { border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.25rem 0.45rem; }
|
||||
.pages { display: flex; gap: 0.25rem; }
|
||||
.page-btn {
|
||||
min-width: 32px; height: 32px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer;
|
||||
min-width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid #e2e8f0; background: #fff; cursor: pointer;
|
||||
}
|
||||
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||||
|
||||
@@ -883,12 +913,12 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
.goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; }
|
||||
.goal-body strong { display: block; }
|
||||
.goal-body .muted, .goal-body small { display: block; font-size: 0.8rem; }
|
||||
.goal-body p { margin: 0.25rem 0; font-weight: 800; font-size: 0.9rem; }
|
||||
.goal-body p { margin: 0.25rem 0; font-weight: 600; font-size: 0.9rem; }
|
||||
.sum-row {
|
||||
display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.45rem 0;
|
||||
border-bottom: 1px solid #f1f5f9; font-size: 0.9rem;
|
||||
}
|
||||
.sum-row.payable { color: var(--teal); font-weight: 800; border-bottom: 0; }
|
||||
.sum-row.payable { color: var(--teal); font-weight: 600; border-bottom: 0; }
|
||||
.forecast-summary { padding: 1rem; }
|
||||
.mob-bottom {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
@@ -897,7 +927,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
|
||||
}
|
||||
.fab {
|
||||
border: 0; border-radius: 10px; min-height: 36px; background: var(--teal); color: #fff; font-weight: 800;
|
||||
border: 0; border-radius: var(--radius); min-height: 36px; background: var(--teal); color: #fff; font-weight: 600;
|
||||
padding: 0 1rem; cursor: pointer;
|
||||
}
|
||||
.side-link {
|
||||
@@ -929,8 +959,8 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
background: #f1f5f9; border-radius: var(--radius);
|
||||
}
|
||||
.mob-tabs button {
|
||||
border: 0; border-radius: 8px; min-height: var(--control-h); height: var(--control-h); background: transparent; color: #64748b;
|
||||
font-weight: 800; cursor: pointer;
|
||||
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h); background: transparent; color: #64748b;
|
||||
font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.mob-tabs button.on { background: var(--teal); color: #fff; }
|
||||
.mob-filter { flex: 1; }
|
||||
|
||||
@@ -9,6 +9,16 @@ import type {
|
||||
UpdateTahesabConnectionRequest,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -221,29 +231,27 @@ async function removeConn(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTahesab(mode: 'filter' | 'range' = 'filter') {
|
||||
async function searchTahesab(mode: 'range' | 'filter') {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
syncResults.value = [];
|
||||
if (!sync.connectionId) {
|
||||
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
||||
error.value = 'ابتدا یک اتصال انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
syncBusy.value = true;
|
||||
try {
|
||||
syncBusy.value = true;
|
||||
const body: Record<string, unknown> = {
|
||||
connectionId: sync.connectionId,
|
||||
};
|
||||
const body: Record<string, unknown> = { connectionId: sync.connectionId };
|
||||
if (mode === 'range') {
|
||||
const fromRaw = String(sync.fromCode ?? '').trim();
|
||||
const toRaw = String(sync.toCode ?? '').trim();
|
||||
if (!fromRaw && !toRaw) {
|
||||
body.listAll = true;
|
||||
body.fromCode = 1;
|
||||
body.toCode = 5000;
|
||||
} else {
|
||||
const fromCode = Number(fromRaw);
|
||||
const toCode = Number(toRaw);
|
||||
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
|
||||
error.value = 'بازهٔ کد را درست وارد کنید، یا هر دو را خالی بگذارید تا همه نمایش داده شوند.';
|
||||
const fromCode = fromRaw ? Number(fromRaw) : 1;
|
||||
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
|
||||
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
|
||||
error.value = 'بازه کد مشتری نامعتبر است.';
|
||||
return;
|
||||
}
|
||||
body.fromCode = fromCode;
|
||||
@@ -328,22 +336,27 @@ async function importBulk(mode: 'results' | 'range') {
|
||||
if (!fromRaw && !toRaw) {
|
||||
if (
|
||||
!confirm(
|
||||
`همهٔ مشتریان تهحساب وارد ${brand.nameFa} میشوند (قبلاً لینکشدهها رد میشوند). ممکن است کمی طول بکشد. ادامه؟`,
|
||||
`کل مشتریان از کد ۱ تا ۵۰۰۰ از تهحساب بهعنوان شخص وارد ${brand.nameFa} میشوند. ادامه؟`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
body.listAll = true;
|
||||
body.fromCode = 1;
|
||||
body.toCode = 5000;
|
||||
} else {
|
||||
const fromCode = Number(fromRaw);
|
||||
const toCode = Number(toRaw);
|
||||
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
|
||||
error.value = 'بازهٔ کد را درست وارد کنید، یا هر دو را خالی بگذارید تا همه وارد شوند.';
|
||||
const fromCode = fromRaw ? Number(fromRaw) : 1;
|
||||
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
|
||||
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
|
||||
error.value = 'بازه نامعتبر است.';
|
||||
return;
|
||||
}
|
||||
if (toCode - fromCode > 2000) {
|
||||
error.value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`همهٔ مشتریان تهحساب با کد ${fromCode} تا ${toCode} وارد ${brand.nameFa} میشوند (قبلاً لینکشدهها رد میشوند). ادامه؟`,
|
||||
`مشتریان با کد ${fromCode} تا ${toCode} از تهحساب وارد ${brand.nameFa} میشوند. ادامه؟`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -372,482 +385,469 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>اتصال تهحساب</h1>
|
||||
<p class="lead">
|
||||
هر نسخه/دیتابیس تهحساب یک اتصال جدا است (آدرس وبسرویس + توکن + نام DB).
|
||||
اگر فقط یک اتصال دارید، همان بهعنوان پیشفرض استفاده میشود.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="crm-page tahesab-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="اتصال نرمافزار مالی تهحساب"
|
||||
description="همگامسازی اشخاص، ماندهحسابها و اسناد مالی بین تهحساب و نرمافزار مدیریت ارتباط با مشتریان"
|
||||
/>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
||||
|
||||
<div
|
||||
v-if="items.some((r) => !r.allowInsecureTls && isTlsError(r.lastError))"
|
||||
class="banner warn"
|
||||
class="crm-alert crm-alert-warning"
|
||||
>
|
||||
اتصال شما بهخاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
|
||||
<strong>فعالسازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید.
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && items.length" class="list">
|
||||
<article v-for="row in items" :key="row.id" class="card row">
|
||||
<div class="row-main">
|
||||
<strong>{{ row.label }}</strong>
|
||||
<span class="muted">{{ row.baseUrl }}</span>
|
||||
<span class="meta">
|
||||
DB: {{ row.dbName }} · توکن: {{ row.tokenMasked }}
|
||||
<template v-if="row.isDefault"> · پیشفرض</template>
|
||||
<template v-if="!row.enabled"> · غیرفعال</template>
|
||||
<template v-if="row.allowInsecureTls"> · SSL خودامضا مجاز</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="row.lastHealthAt"
|
||||
class="health"
|
||||
:class="row.lastHealthOk ? 'ok' : 'bad'"
|
||||
>
|
||||
آخرین تست:
|
||||
{{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}
|
||||
<template v-if="row.lastError"> — {{ row.lastError }}</template>
|
||||
</span>
|
||||
<UiCard v-if="!loading && items.length" title="اتصالات تعریفشده" class="section-card">
|
||||
<div class="connections-list">
|
||||
<div v-for="row in items" :key="row.id" class="connection-row">
|
||||
<div class="connection-info">
|
||||
<div class="connection-title-row">
|
||||
<strong class="connection-label">{{ row.label }}</strong>
|
||||
<UiBadge v-if="row.isDefault" variant="primary">پیشفرض</UiBadge>
|
||||
<UiBadge :variant="row.enabled ? 'success' : 'neutral'">
|
||||
{{ row.enabled ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="row.allowInsecureTls" variant="warning">SSL خودامضا</UiBadge>
|
||||
</div>
|
||||
<p class="connection-url font-mono">{{ row.baseUrl }}</p>
|
||||
<p class="connection-meta">
|
||||
<span>بانک: {{ row.dbName }}</span>
|
||||
<span class="meta-dot">·</span>
|
||||
<span>توکن: {{ row.tokenMasked }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="row.lastHealthAt"
|
||||
class="health-status"
|
||||
:class="row.lastHealthOk ? 'ok' : 'bad'"
|
||||
>
|
||||
<span class="health-indicator"></span>
|
||||
<span>آخرین وضعیت: {{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}</span>
|
||||
<span v-if="row.lastError"> — {{ row.lastError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-actions">
|
||||
<UiButton
|
||||
v-if="!row.allowInsecureTls && isTlsError(row.lastError)"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="enableInsecureTls(row.id)"
|
||||
>
|
||||
فعالسازی SSL خودامضا
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="startEdit(row)">
|
||||
<template #icon>
|
||||
<UiIcon name="pencil" />
|
||||
</template>
|
||||
ویرایش
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="testConn(row.id)">
|
||||
<template #icon>
|
||||
<UiIcon name="refresh" />
|
||||
</template>
|
||||
تست اتصال
|
||||
</UiButton>
|
||||
<UiButton variant="danger" size="sm" @click="removeConn(row.id)">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
v-if="!row.allowInsecureTls && isTlsError(row.lastError)"
|
||||
type="button"
|
||||
class="btn primary"
|
||||
@click="enableInsecureTls(row.id)"
|
||||
>
|
||||
فعالسازی SSL خودامضا
|
||||
</button>
|
||||
<button type="button" class="btn ghost" @click="startEdit(row)">ویرایش</button>
|
||||
<button type="button" class="btn ghost" @click="testConn(row.id)">تست</button>
|
||||
<button type="button" class="btn danger" @click="removeConn(row.id)">حذف</button>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard :title="editingId ? 'ویرایش اتصال تهحساب' : 'افزودن اتصال جدید تهحساب'" class="section-card">
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="عنوان اتصال" required>
|
||||
<UiInput v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه مرکزی، دیتابیس مالی" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="آدرس وبسرویس (Base URL)" required hint="آدرس سرور وبسرویس تهحساب">
|
||||
<UiInput
|
||||
v-model="form.baseUrl"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="https://IP:8081 یا https://api.tahesab.ir"
|
||||
@blur="onBaseUrlBlur"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="نام دیتابیس (DBName)" required>
|
||||
<UiInput v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="توکن دسترسی (Bearer Token)" :required="!editingId" :hint="editingId ? 'خالی بگذارید تا توکن فعلی تغییر نکند.' : 'توکن احراز هویت وبسرویس'">
|
||||
<UiInput
|
||||
v-model="form.token"
|
||||
:required="!editingId"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autocomplete="new-password"
|
||||
placeholder="توکن امنیتی..."
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form class="card" @submit.prevent="save">
|
||||
<h2>{{ editingId ? 'ویرایش اتصال' : 'افزودن اتصال' }}</h2>
|
||||
<div class="field">
|
||||
<label>برچسب</label>
|
||||
<input v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه بازار" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>آدرس وبسرویس (base URL)</label>
|
||||
<input
|
||||
v-model="form.baseUrl"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="https://api-test.tahesab.ir یا https://IP:8081"
|
||||
@blur="onBaseUrlBlur"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>نام دیتابیس (DBname)</label>
|
||||
<input v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>
|
||||
توکن
|
||||
<span v-if="editingId" class="muted">(خالی بگذارید تا عوض نشود)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.token"
|
||||
:required="!editingId"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autocomplete="new-password"
|
||||
placeholder="Bearer token"
|
||||
/>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
اتصال پیشفرض
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
فعال
|
||||
</label>
|
||||
<label class="check highlight">
|
||||
<input v-model="form.allowInsecureTls" type="checkbox" />
|
||||
پذیرش گواهی SSL خودامضا (برای IP داخلی / https بدون گواهی معتبر — معمولاً لازم است)
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : editingId ? 'بهروزرسانی' : 'افزودن' }}
|
||||
</button>
|
||||
<button v-if="editingId" type="button" class="btn ghost" @click="resetForm">انصراف</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="checkbox-group">
|
||||
<label class="custom-check">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
<span>اتصال پیشفرض سازمان</span>
|
||||
</label>
|
||||
<label class="custom-check">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>اتصال فعال باشد</span>
|
||||
</label>
|
||||
<label class="custom-check highlight">
|
||||
<input v-model="form.allowInsecureTls" type="checkbox" />
|
||||
<span>پذیرش گواهی SSL خودامضا (برای IP محلی یا پورت داخلی بدون گواهی رسمی)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="card sync">
|
||||
<h2>وارد کردن مشتریان از تهحساب</h2>
|
||||
<p class="muted">
|
||||
فقط «افزودن اتصال» مشتریها را نمیآورد. باید با بازهٔ کد یا جستجو آنها را از تهحساب
|
||||
<strong>وارد</strong> کنید تا در «اشخاص و شرکتها» دیده شوند. جزئیات مانده/اسناد بعد از لینک، در پروفایل شخص → تب دارایی است.
|
||||
<div class="form-actions-row">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
{{ editingId ? 'بهروزرسانی اتصال' : 'افزودن اتصال' }}
|
||||
</UiButton>
|
||||
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="وارد کردن و همگامسازی مشتریان از تهحساب" class="section-card">
|
||||
<p class="sync-desc">
|
||||
با استفاده از این بخش میتوانید اشخاص و طرفحسابهای تهحساب را به عنوان مخاطب وارد نرمافزار کرده و سوابق مالی آنها را پیوند دهید.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label>اتصال</label>
|
||||
<select v-model="sync.connectionId">
|
||||
<option disabled value="">انتخاب اتصال</option>
|
||||
<option v-for="row in items" :key="row.id" :value="row.id">
|
||||
{{ row.label }} ({{ row.dbName }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3 class="subhead">۱) وارد کردن دستهای با بازهٔ کد مشتری</h3>
|
||||
<p class="muted small">
|
||||
اگر «از» و «تا» خالی باشند، مشتریان با کد ۱ تا ۵۰۰۰ از تهحساب لیست میشوند.
|
||||
برای کدهای بالاتر بازه را دستی بگذارید (حداکثر ۲۰۰۰ کد در هر درخواست محدود).
|
||||
</p>
|
||||
<div class="sync-grid range">
|
||||
<input v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (خالی = همه)" />
|
||||
<input v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (خالی = همه)" />
|
||||
</div>
|
||||
<div class="sync-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="searchTahesab('range')"
|
||||
>
|
||||
نمایش بازه
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="importBulk('range')"
|
||||
>
|
||||
وارد کردن کل بازه
|
||||
</button>
|
||||
</div>
|
||||
<div class="crm-form">
|
||||
<UiField label="اتصال مورد نظر جهت همگامسازی" required>
|
||||
<UiSelect v-model="sync.connectionId">
|
||||
<option disabled value="">انتخاب اتصال</option>
|
||||
<option v-for="row in items" :key="row.id" :value="row.id">
|
||||
{{ row.label }} ({{ row.dbName }})
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<h3 class="subhead">۲) جستجوی تکی</h3>
|
||||
<div class="sync-grid">
|
||||
<input v-model="sync.phone" dir="ltr" placeholder="موبایل" />
|
||||
<input v-model="sync.name" placeholder="نام" />
|
||||
<input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" />
|
||||
</div>
|
||||
<div class="sync-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="searchTahesab('filter')"
|
||||
>
|
||||
جستجو
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
|
||||
@click="importBulk('results')"
|
||||
>
|
||||
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">۱) وارد کردن دستهای با بازه کد مشتری</h4>
|
||||
<p class="sync-sub-desc">
|
||||
در صورت خالی بودن «از» و «تا»، مشتریان کد ۱ تا ۵۰۰۰ بررسی میشوند (حداکثر ۲۰۰۰ کد در هر درخواست).
|
||||
</p>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" />
|
||||
<UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (مثلاً ۵۰۰)" />
|
||||
</div>
|
||||
<div class="sync-buttons">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="searchTahesab('range')"
|
||||
>
|
||||
نمایش بازه
|
||||
</UiButton>
|
||||
<UiButton
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="importBulk('range')"
|
||||
>
|
||||
وارد کردن کل بازه
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-if="syncResults.length" class="sync-results">
|
||||
<li v-for="r in syncResults" :key="String(r.moshtariCode)">
|
||||
<span>
|
||||
{{ r.moshtariCode }} — {{ r.name || 'بدون نام' }}
|
||||
<span class="muted">{{ r.tel }}</span>
|
||||
<span v-if="r.alreadyLinked" class="linked">لینکشده{{ r.personName ? ` · ${r.personName}` : '' }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="!r.alreadyLinked"
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="!r.moshtariCode || syncBusy"
|
||||
@click="importOne(r.moshtariCode!)"
|
||||
>
|
||||
وارد کردن
|
||||
</button>
|
||||
<RouterLink
|
||||
v-else-if="r.personId"
|
||||
class="btn ghost"
|
||||
:to="{ name: 'person-detail', params: { id: r.personId } }"
|
||||
>
|
||||
مشاهده شخص
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">۲) جستجوی تکی یا فیلتر مشخصات</h4>
|
||||
<div class="crm-form-grid-3">
|
||||
<UiInput v-model="sync.phone" dir="ltr" placeholder="موبایل یا تلفن..." />
|
||||
<UiInput v-model="sync.name" placeholder="نام یا عنوان مشتری..." />
|
||||
<UiInput v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری تهحساب..." />
|
||||
</div>
|
||||
<div class="sync-buttons">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="searchTahesab('filter')"
|
||||
>
|
||||
جستجو در تهحساب
|
||||
</UiButton>
|
||||
<UiButton
|
||||
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
|
||||
@click="importBulk('results')"
|
||||
>
|
||||
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="syncResults.length" class="sync-results-box">
|
||||
<h4 class="sync-results-title">نتایج جستجو ({{ syncResults.length }})</h4>
|
||||
<div class="crm-table-container">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کد مشتری</th>
|
||||
<th>نام و نام خانوادگی</th>
|
||||
<th>تلفن</th>
|
||||
<th>وضعیت لینک</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in syncResults" :key="String(r.moshtariCode)">
|
||||
<td class="font-mono">{{ r.moshtariCode }}</td>
|
||||
<td class="font-bold">{{ r.name || 'بدون نام' }}</td>
|
||||
<td class="font-mono" dir="ltr">{{ r.tel }}</td>
|
||||
<td>
|
||||
<UiBadge v-if="r.alreadyLinked" variant="success">
|
||||
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
|
||||
</UiBadge>
|
||||
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<UiButton
|
||||
v-if="!r.alreadyLinked"
|
||||
size="sm"
|
||||
:disabled="!r.moshtariCode || syncBusy"
|
||||
@click="importOne(r.moshtariCode!)"
|
||||
>
|
||||
وارد کردن
|
||||
</UiButton>
|
||||
<RouterLink
|
||||
v-else-if="r.personId"
|
||||
:to="{ name: 'person-detail', params: { id: r.personId } }"
|
||||
>
|
||||
<UiButton variant="ghost" size="sm">
|
||||
مشاهده شخص
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 720px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
.tahesab-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.connections-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.connection-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
.connection-row:hover {
|
||||
border-color: var(--color-brand-300, #5eead4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.health.ok {
|
||||
color: #15803d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.health.bad {
|
||||
color: #b91c1c;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
.connection-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input:not([type]),
|
||||
input[type='password'],
|
||||
input[type='url'],
|
||||
input[type='number'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.7rem;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.7rem;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.check {
|
||||
.connection-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.check.highlight {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
font-weight: 700;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.banner.warn {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 14px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
color: #9a3412;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.connection-label {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.connection-url {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-meta {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.health-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.health-status.ok {
|
||||
color: var(--color-success-text, #15803d);
|
||||
}
|
||||
|
||||
.health-status.ok .health-indicator {
|
||||
background: var(--color-success-text, #15803d);
|
||||
}
|
||||
|
||||
.health-status.bad {
|
||||
color: var(--color-danger-text, #b91c1c);
|
||||
}
|
||||
|
||||
.health-status.bad .health-indicator {
|
||||
background: var(--color-danger-text, #b91c1c);
|
||||
}
|
||||
|
||||
.connection-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.custom-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-color: #99f6e4;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
.custom-check.highlight {
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
border: 1px solid var(--color-warning-border, #fef3c7);
|
||||
color: var(--color-warning-text, #92400e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sync-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-grid.range {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.sync-actions {
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.subhead {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f766e;
|
||||
.sync-desc {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.muted.small {
|
||||
font-size: 0.85rem;
|
||||
.sync-sub-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
background: var(--color-surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.linked {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
.sync-subhead {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.sync-results {
|
||||
list-style: none;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-results li {
|
||||
.sync-sub-desc {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sync-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sync-results-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.sync-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sync-results-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-actions,
|
||||
.form-actions,
|
||||
.sync-results li {
|
||||
.font-mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.connection-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.connection-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,17 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { canViewAllRecords } from '@/lib/roles';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateTicketRequest,
|
||||
@@ -24,6 +35,7 @@ const id = computed(() =>
|
||||
typeof route.params.id === 'string' ? route.params.id : '',
|
||||
);
|
||||
const isNew = computed(() => route.name === 'ticket-new');
|
||||
const pageTitle = computed(() => (isNew.value ? 'تیکت جدید' : number.value || 'تیکت'));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -33,8 +45,8 @@ const error = ref('');
|
||||
const number = ref('');
|
||||
const subject = ref('');
|
||||
const description = ref('');
|
||||
const status = ref<TicketStatus>('open');
|
||||
const priority = ref<TicketPriority>('normal');
|
||||
const status = ref<string>('open');
|
||||
const priority = ref<string>('normal');
|
||||
const personId = ref('');
|
||||
const companyId = ref('');
|
||||
const assigneeId = ref('');
|
||||
@@ -138,8 +150,8 @@ async function load() {
|
||||
number.value = data.number;
|
||||
subject.value = data.subject;
|
||||
description.value = data.description ?? '';
|
||||
status.value = data.status as TicketStatus;
|
||||
priority.value = data.priority as TicketPriority;
|
||||
status.value = data.status;
|
||||
priority.value = data.priority;
|
||||
personId.value = data.person?.id ?? '';
|
||||
companyId.value = data.company?.id ?? '';
|
||||
assigneeId.value = data.assignee?.id ?? '';
|
||||
@@ -168,8 +180,8 @@ function payload(): CreateTicketRequest {
|
||||
return {
|
||||
subject: subject.value.trim(),
|
||||
description: description.value || undefined,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
status: status.value as TicketStatus,
|
||||
priority: priority.value as TicketPriority,
|
||||
personId: personId.value || null,
|
||||
companyId: companyId.value || null,
|
||||
assigneeId: assigneeId.value || null,
|
||||
@@ -266,21 +278,49 @@ async function remove() {
|
||||
await router.push({ name: 'tickets' });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void router.push({ name: 'tickets' });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<p class="crumb">
|
||||
<section class="crm-page narrow">
|
||||
<UiPageHeader
|
||||
:title="pageTitle"
|
||||
:back-to="{ name: 'tickets' }"
|
||||
>
|
||||
<template #lead>
|
||||
<span class="crumb">
|
||||
<RouterLink :to="{ name: 'tickets' }">تیکتها</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ isNew ? 'تیکت جدید' : number || 'تیکت' }}</strong>
|
||||
</p>
|
||||
<h1>{{ isNew ? 'تیکت جدید' : number || 'تیکت' }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="!isNew"
|
||||
variant="danger"
|
||||
:disabled="saving"
|
||||
@click="remove"
|
||||
>
|
||||
حذف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
@@ -290,137 +330,163 @@ onMounted(load);
|
||||
class="sla-box"
|
||||
:class="{ bad: ticket.slaBreached }"
|
||||
>
|
||||
<template v-if="ticket.slaBreached">مهلت پاسخ اول گذشته است.</template>
|
||||
<template v-else-if="ticket.firstRespondedAt">
|
||||
پاسخ اول ثبت شد ({{ formatWhen(ticket.firstRespondedAt) }}).
|
||||
</template>
|
||||
<template v-else>
|
||||
مهلت پاسخ اول: {{ formatWhen(ticket.slaDueAt) }}
|
||||
</template>
|
||||
<UiIcon name="clock" :size="18" />
|
||||
<div>
|
||||
<template v-if="ticket.slaBreached">
|
||||
مهلت پاسخ اول گذشته است.
|
||||
</template>
|
||||
<template v-else-if="ticket.firstRespondedAt">
|
||||
پاسخ اول ثبت شد ({{ formatWhen(ticket.firstRespondedAt) }}).
|
||||
</template>
|
||||
<template v-else>
|
||||
مهلت پاسخ اول: {{ formatWhen(ticket.slaDueAt) }}
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<form class="form" @submit.prevent="save">
|
||||
<div class="grid">
|
||||
<div class="field wide">
|
||||
<label>موضوع</label>
|
||||
<input v-model="subject" required maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>وضعیت</label>
|
||||
<select v-model="status">
|
||||
<option
|
||||
v-for="(label, key) in statusLabel"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>اولویت</label>
|
||||
<select v-model="priority">
|
||||
<option
|
||||
v-for="(label, key) in priorityLabel"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>شخص</label>
|
||||
<select v-model="personId">
|
||||
<option value="">بدون شخص</option>
|
||||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||
{{ p.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>شرکت</label>
|
||||
<select v-model="companyId">
|
||||
<option value="">بدون شرکت</option>
|
||||
<option v-for="c in companies" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="canAssignOthers" class="field">
|
||||
<label>مسئول</label>
|
||||
<select v-model="assigneeId">
|
||||
<option value="">بدون مسئول</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>{{ isNew ? 'شرح اولیه' : 'شرح' }}</label>
|
||||
<textarea v-model="description" rows="4" />
|
||||
</div>
|
||||
</div>
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<UiCard>
|
||||
<div class="crm-form-grid-2">
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="موضوع" required for-id="subject">
|
||||
<UiInput
|
||||
id="subject"
|
||||
v-model="subject"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="موضوع تیکت را وارد کنید…"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<div v-if="isNew && faqHints.length" class="faq-hints">
|
||||
<p class="faq-title">شاید این FAQ کمک کند:</p>
|
||||
<details v-for="f in faqHints" :key="f.id">
|
||||
<UiField label="وضعیت" for-id="status">
|
||||
<UiSelect id="status" v-model="status">
|
||||
<option
|
||||
v-for="(label, key) in statusLabel"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="اولویت" for-id="priority">
|
||||
<UiSelect id="priority" v-model="priority">
|
||||
<option
|
||||
v-for="(label, key) in priorityLabel"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="شخص مرتبط" for-id="personId">
|
||||
<UiSelect id="personId" v-model="personId">
|
||||
<option value="">بدون شخص</option>
|
||||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||
{{ p.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="شرکت مرتبط" for-id="companyId">
|
||||
<UiSelect id="companyId" v-model="companyId">
|
||||
<option value="">بدون شرکت</option>
|
||||
<option v-for="c in companies" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField v-if="canAssignOthers" label="مسئول" for-id="assigneeId">
|
||||
<UiSelect id="assigneeId" v-model="assigneeId">
|
||||
<option value="">بدون مسئول</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField :label="isNew ? 'شرح اولیه' : 'شرح'" for-id="description">
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
:rows="4"
|
||||
placeholder="جزئیات و توضیحات تیکت را بنویسید…"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="isNew && faqHints.length" class="faq-hints-card" padding="sm">
|
||||
<p class="faq-title">
|
||||
<UiIcon name="lightbulb" :size="16" />
|
||||
شاید این مقالات راهنما (FAQ) کمک کند:
|
||||
</p>
|
||||
<details v-for="f in faqHints" :key="f.id" class="faq-item">
|
||||
<summary>{{ f.question }}</summary>
|
||||
<p>{{ f.answer }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isNew"
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="remove"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="showCsat" class="csat">
|
||||
<h2>رضایت مشتری (CSAT)</h2>
|
||||
<div class="csat-scores">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="score"
|
||||
:class="{ active: csatScore === n }"
|
||||
@click="csatScore = n"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
<UiCard v-if="showCsat" class="csat-card">
|
||||
<template #header>
|
||||
<div class="csat-header">
|
||||
<h3>رضایت مشتری (CSAT)</h3>
|
||||
<span v-if="ticket?.csatAt" class="muted">
|
||||
آخرین ثبت: {{ formatWhen(ticket.csatAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="csat-body">
|
||||
<div class="csat-scores">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="score"
|
||||
:class="{ active: csatScore === n }"
|
||||
@click="csatScore = n"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
<UiTextarea
|
||||
v-model="csatComment"
|
||||
:rows="2"
|
||||
placeholder="نظر یا بازخورد مشتری (اختیاری)…"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="csatComment"
|
||||
rows="2"
|
||||
placeholder="نظر اختیاری…"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="csatSaving || csatScore == null"
|
||||
@click="saveCsat"
|
||||
>
|
||||
{{ csatSaving ? '…' : ticket?.csatAt ? 'بهروزرسانی رضایت' : 'ثبت رضایت' }}
|
||||
</button>
|
||||
<p v-if="ticket?.csatAt" class="muted">
|
||||
آخرین ثبت: {{ formatWhen(ticket.csatAt) }}
|
||||
</p>
|
||||
</section>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="csatSaving || csatScore == null"
|
||||
:loading="csatSaving"
|
||||
@click="saveCsat"
|
||||
>
|
||||
{{ ticket?.csatAt ? 'بهروزرسانی رضایت' : 'ثبت رضایت' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="!isNew && ticket" class="thread-card">
|
||||
<template #header>
|
||||
<div class="thread-header">
|
||||
<h3>گفتگو و پیامها</h3>
|
||||
<UiBadge variant="neutral" size="sm">
|
||||
{{ ticket.messages.length }} پیام
|
||||
</UiBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-if="!isNew && ticket" class="thread">
|
||||
<h2>گفتگو</h2>
|
||||
<div v-if="ticket.messages.length" class="messages">
|
||||
<article
|
||||
v-for="msg in ticket.messages"
|
||||
@@ -431,261 +497,233 @@ onMounted(load);
|
||||
<header>
|
||||
<strong>{{ msg.author?.fullName || 'سیستم' }}</strong>
|
||||
<span class="muted">{{ formatWhen(msg.createdAt) }}</span>
|
||||
<span v-if="msg.isInternal" class="tag">داخلی</span>
|
||||
<UiBadge v-if="msg.isInternal" variant="warning" size="sm">داخلی</UiBadge>
|
||||
</header>
|
||||
<p>{{ msg.body }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="muted">هنوز پیامی نیست.</p>
|
||||
<p v-else class="muted empty-messages">هنوز پیامی ثبت نشده است.</p>
|
||||
|
||||
<form class="reply" @submit.prevent="sendReply">
|
||||
<textarea
|
||||
v-model="reply"
|
||||
rows="3"
|
||||
placeholder="پاسخ یا یادداشت…"
|
||||
required
|
||||
/>
|
||||
<label class="check">
|
||||
<input v-model="replyInternal" type="checkbox" />
|
||||
یادداشت داخلی (فقط تیم)
|
||||
</label>
|
||||
<button class="btn" type="submit" :disabled="messaging">
|
||||
{{ messaging ? '…' : 'ارسال' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<template #footer>
|
||||
<form class="reply-form" @submit.prevent="sendReply">
|
||||
<UiTextarea
|
||||
v-model="reply"
|
||||
:rows="3"
|
||||
placeholder="پاسخ به تیکت یا یادداشت جدید…"
|
||||
required
|
||||
/>
|
||||
<div class="reply-actions">
|
||||
<label class="check">
|
||||
<input v-model="replyInternal" type="checkbox" />
|
||||
<span>یادداشت داخلی (فقط همکاران)</span>
|
||||
</label>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
:disabled="messaging || !reply.trim()"
|
||||
:loading="messaging"
|
||||
>
|
||||
ارسال پیام
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</UiCard>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 760px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
margin: 0 0 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
gap: var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: #0f766e;
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 2.5vw, 1.55rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.form,
|
||||
.thread,
|
||||
.csat {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.form,
|
||||
.thread,
|
||||
.csat {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.sla-box {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #99f6e4;
|
||||
background: #f0fdfa;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-primary-light);
|
||||
background: var(--color-primary-surface);
|
||||
color: var(--color-primary-dark);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sla-box.bad {
|
||||
border-color: color-mix(in srgb, #b42318 40%, var(--line));
|
||||
background: color-mix(in srgb, #b42318 8%, #fff);
|
||||
color: #b42318;
|
||||
border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.faq-hints {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
.faq-hints-card {
|
||||
border: 1px dashed var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.faq-title {
|
||||
margin: 0;
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.faq-hints details {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.faq-hints p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.csat,
|
||||
.thread {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
.faq-item {
|
||||
font-size: var(--fs-sm);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.faq-item p {
|
||||
margin: var(--sp-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.csat-card,
|
||||
.thread-card {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.csat-header,
|
||||
.thread-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.csat-header h3,
|
||||
.thread-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.csat-body {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.csat-scores {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.score {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-base);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.score:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-surface);
|
||||
}
|
||||
|
||||
.score.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, #fff);
|
||||
font-weight: 700;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.msg {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.msg.internal {
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
background: var(--color-warning-surface);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
|
||||
}
|
||||
|
||||
.msg header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.msg p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
.empty-messages {
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reply {
|
||||
.reply-form {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: inherit;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,11 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiPageHeader,
|
||||
UiEmptyState,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
@@ -127,15 +132,19 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<section class="page" @click="filterOpen = false">
|
||||
<header class="page-head">
|
||||
<div class="head-copy">
|
||||
<h1>تیکتها</h1>
|
||||
<p class="lead">درخواستهای پشتیبانی را ثبت و پیگیری کنید.</p>
|
||||
</div>
|
||||
<RouterLink class="add-btn" :to="{ name: 'ticket-new' }" aria-label="تیکت جدید">
|
||||
<span>+</span>
|
||||
</RouterLink>
|
||||
</header>
|
||||
<UiPageHeader
|
||||
title="تیکتها"
|
||||
lead="درخواستهای پشتیبانی را ثبت و پیگیری کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="tools">
|
||||
<form class="search" @submit.prevent="onSearch">
|
||||
@@ -177,25 +186,20 @@ onMounted(load);
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="listEmpty" class="empty-card">
|
||||
<div class="art" aria-hidden="true">
|
||||
<svg viewBox="0 0 168 148" fill="none">
|
||||
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
|
||||
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
|
||||
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
|
||||
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
|
||||
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>هنوز تیکتی نیست</h2>
|
||||
<p>اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند.</p>
|
||||
<RouterLink class="btn primary wide" :to="{ name: 'ticket-new' }">
|
||||
<span>+</span>
|
||||
تیکت جدید
|
||||
</RouterLink>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
v-else-if="listEmpty"
|
||||
title="هنوز تیکتی ثبت نشده است"
|
||||
description="اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||||
>
|
||||
<template #action>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<template v-else>
|
||||
<div class="list-head">
|
||||
@@ -304,7 +308,7 @@ onMounted(load);
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lead,
|
||||
@@ -346,25 +350,38 @@ h1 {
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 400px;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0 0.85rem;
|
||||
color: #94a3b8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
padding: 0.75rem 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-wrap {
|
||||
@@ -377,31 +394,40 @@ h1 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
padding: 0 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-color: #99f6e4;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.btn.ghost.on {
|
||||
background: #f0fdfa;
|
||||
border-color: #14b8a6;
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.btn.wide {
|
||||
@@ -427,7 +453,7 @@ h1 {
|
||||
inset-inline-end: 0;
|
||||
min-width: 11rem;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
@@ -441,7 +467,7 @@ h1 {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
@@ -455,7 +481,7 @@ h1 {
|
||||
|
||||
.empty-card {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
padding: 2.4rem 1.2rem 2rem;
|
||||
display: grid;
|
||||
@@ -487,7 +513,7 @@ h1 {
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -601,7 +627,7 @@ h1 {
|
||||
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Preview — copy to /var/www/crm2/apps/api/.env
|
||||
# Start from production crm .env, then change only the keys below.
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=3032
|
||||
CORS_ORIGIN=https://crm2.montzar.ir
|
||||
JWT_SECRET=change-me-long-random-production-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Separate database — do not point this at mento_crm
|
||||
DATABASE_URL="postgresql://mento:CHANGE_ME@127.0.0.1:15432/mento_crm2?schema=public"
|
||||
|
||||
UPLOAD_DIR=/var/www/crm2/uploads
|
||||
|
||||
PORTAL_SHOW_OTP=false
|
||||
AUTH_RETURN_OTP=false
|
||||
AUTH_EMAIL_OTP_ENABLED=false
|
||||
|
||||
# Copy SMS / AI / STT / encryption keys from /var/www/crm/apps/api/.env
|
||||
SMS_PROVIDER=trez
|
||||
SMS_USERNAME=
|
||||
SMS_PASSWORD=
|
||||
SMS_SENDER=
|
||||
SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
||||
|
||||
AI_ENABLED=true
|
||||
AI_PROVIDER=openai
|
||||
AI_BASE_URL=https://api.gapgpt.app/v1
|
||||
AI_API_KEY=change-me
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_TIMEOUT_MS=45000
|
||||
|
||||
# Share the existing STT sidecar (do not start a second one)
|
||||
STT_ENABLED=true
|
||||
STT_URL=http://127.0.0.1:3040
|
||||
STT_INTERNAL_TOKEN=change-me-stt-internal-token
|
||||
STT_TIMEOUT_MS=60000
|
||||
|
||||
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# پیشنمایش دیزاین — `crm2.montzar.ir`
|
||||
|
||||
نسخهٔ دوم کنار پروداکشن. **به `/var/www/crm`، `mento-crm-api`، پورت `3030` و دیتابیس `mento_crm` دست نزنید.**
|
||||
|
||||
| | پروداکشن | پیشنمایش |
|
||||
|---|---|---|
|
||||
| دامنه | `crm.montzar.ir` | `crm2.montzar.ir` |
|
||||
| مسیر | `/var/www/crm` | `/var/www/crm2` |
|
||||
| برنچ | `main` | `design_ui` |
|
||||
| پورت API | `3030` | `3032` |
|
||||
| PM2 | `mento-crm-api` | `mento-crm2-api` |
|
||||
| دیتابیس | `mento_crm` | `mento_crm2` |
|
||||
| STT | `mento-stt` روی `3040` | همان را share کنید |
|
||||
|
||||
بروزرسانی پروداکشن همچنان فقط بلوک قفلشده در `deploy/DEPLOY.md` است.
|
||||
|
||||
---
|
||||
|
||||
## قبل از سرور
|
||||
|
||||
1. DNS: رکورد `A` برای `crm2.montzar.ir` همان IP پروداکشن (`185.50.38.53`) — مثل `crm.montzar.ir`.
|
||||
2. اول `design_ui` را push کنید، بعد کلون/پول روی سرور.
|
||||
|
||||
---
|
||||
|
||||
## بار اول روی سرور
|
||||
|
||||
```bash
|
||||
ssh -p 3031 montime@185.50.38.53
|
||||
|
||||
sudo mkdir -p /var/www/crm2
|
||||
sudo chown montime:montime /var/www/crm2
|
||||
git clone --branch design_ui http://get.feep.ir/Mahdi/CRM_Montzar.git /var/www/crm2
|
||||
|
||||
# دیتابیس جدا (خالی امنتر است؛ برای دادهٔ شبیه پروداکشن dump بگیرید)
|
||||
# CREATE DATABASE mento_crm2 OWNER mento;
|
||||
# اختیاری:
|
||||
# pg_dump -p 15432 -d mento_crm | psql -p 15432 -d mento_crm2
|
||||
|
||||
cp /var/www/crm/apps/api/.env /var/www/crm2/apps/api/.env
|
||||
nano /var/www/crm2/apps/api/.env
|
||||
```
|
||||
|
||||
در `.env` فقط اینها را عوض کنید:
|
||||
|
||||
```text
|
||||
PORT=3032
|
||||
CORS_ORIGIN=https://crm2.montzar.ir
|
||||
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:15432/mento_crm2?schema=public"
|
||||
UPLOAD_DIR=/var/www/crm2/uploads
|
||||
```
|
||||
|
||||
`STT_URL` را روی `http://127.0.0.1:3040` بگذارید. `mento-stt` را دوباره start نکنید.
|
||||
|
||||
وابستگیها (کپی از پروداکشن تا `npm ci` لازم نباشد):
|
||||
|
||||
```bash
|
||||
cp -a /var/www/crm/node_modules /var/www/crm2/
|
||||
rm -rf /var/www/crm2/node_modules/@mento/shared
|
||||
ln -s ../../packages/shared /var/www/crm2/node_modules/@mento/shared
|
||||
chmod +x /var/www/crm2/node_modules/.bin/*
|
||||
find /var/www/crm2/node_modules -path '*esbuild*' -name 'esbuild' -type f -exec chmod +x {} \;
|
||||
```
|
||||
|
||||
بیلد و migrate **فقط روی crm2**:
|
||||
|
||||
```bash
|
||||
cd /var/www/crm2
|
||||
npm run build -w @mento/shared
|
||||
cd apps/api && npx prisma migrate deploy && npx prisma generate && cd ../..
|
||||
npm run build -w @mento/api
|
||||
npm run build -w @mento/web
|
||||
mkdir -p uploads
|
||||
|
||||
pm2 start /var/www/crm2/deploy/ecosystem-crm2.config.cjs
|
||||
pm2 save
|
||||
curl -sI http://127.0.0.1:3032/api/health
|
||||
|
||||
sudo cp /var/www/crm2/deploy/nginx-crm2.montzar.ir.conf /etc/nginx/sites-available/crm2.montzar.ir
|
||||
sudo ln -sf /etc/nginx/sites-available/crm2.montzar.ir /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
vhost را HTTP :80 نگه دارید (پروکسی جلویی SSL را تمام میکند). Certbot روی این باکس نزنید.
|
||||
|
||||
---
|
||||
|
||||
## آپدیت بعدی پیشنمایش (بعد از push روی `design_ui`)
|
||||
|
||||
```bash
|
||||
ssh -p 3031 montime@185.50.38.53
|
||||
cd /var/www/crm2
|
||||
git fetch origin
|
||||
git checkout design_ui
|
||||
git pull origin design_ui
|
||||
|
||||
npm run build -w @mento/shared
|
||||
cd apps/api && npx prisma migrate deploy && npx prisma generate && cd ../..
|
||||
npm run build -w @mento/api
|
||||
npm run build -w @mento/web
|
||||
pm2 restart mento-crm2-api
|
||||
```
|
||||
|
||||
`pm2 restart mento-crm-api` نکنید. `cd /var/www/crm` نکنید.
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PM2 — Mento CRM preview (crm2.montzar.ir)
|
||||
* Port 3032. Does not start STT; reuse mento-stt on 3040.
|
||||
*
|
||||
* pm2 start /var/www/crm2/deploy/ecosystem-crm2.config.cjs
|
||||
* pm2 save
|
||||
*
|
||||
* Never restart mento-crm-api from this file.
|
||||
*/
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "mento-crm2-api",
|
||||
cwd: "/var/www/crm2/apps/api",
|
||||
script: "dist/src/main.js",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: "3032",
|
||||
},
|
||||
max_memory_restart: "600M",
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
# Preview instance — crm2.montzar.ir
|
||||
# Place under /etc/nginx/sites-available/crm2.montzar.ir
|
||||
# HTTP only (same as crm): front proxy terminates SSL.
|
||||
#
|
||||
# Do not replace crm.montzar.ir / adm / ktg / montime vhosts.
|
||||
|
||||
upstream mento_crm2_api {
|
||||
server 127.0.0.1:3032;
|
||||
keepalive 8;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name crm2.montzar.ir;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
root /var/www/crm2/apps/web/dist;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://mento_crm2_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
-26
@@ -287,7 +287,6 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -305,7 +304,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -323,7 +321,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -341,7 +338,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -359,7 +355,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -377,7 +372,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -395,7 +389,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -413,7 +406,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -431,7 +423,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -449,7 +440,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -467,7 +457,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -485,7 +474,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -503,7 +491,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -521,7 +508,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -539,7 +525,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -557,7 +542,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -575,7 +559,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -593,7 +576,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -611,7 +593,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -629,7 +610,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -647,7 +627,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -665,7 +644,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -683,7 +661,6 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -701,7 +678,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -719,7 +695,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -737,7 +712,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user