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:
2026-09-17 16:46:00 +03:30
co-authored by Cursor
87 changed files with 6722 additions and 5040 deletions
+3 -3
View File
@@ -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;
}
+47 -58
View File
@@ -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>
+2 -2
View File
@@ -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;
}
+14 -14
View File
@@ -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;
}
+2 -2
View File
@@ -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%;
+59 -48
View File
@@ -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>
+23 -2
View File
@@ -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>
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+88
View File
@@ -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>
+158 -15
View File
@@ -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>
+109 -4
View File
@@ -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>
+67 -7
View File
@@ -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>
+104
View File
@@ -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>
+3 -2
View File
@@ -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>
+95 -21
View File
@@ -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>
+89 -69
View File
@@ -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>
+123
View File
@@ -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>
+142
View File
@@ -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>
+8 -2
View File
@@ -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 {
+61 -10
View File
@@ -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;
+81
View File
@@ -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>
+15
View File
@@ -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';
+3 -3
View File
@@ -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;
+119 -4
View File
@@ -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 {
+496
View File
@@ -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;
+77 -64
View File
@@ -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;
}
+7 -8
View File
@@ -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;
}
+118 -115
View File
@@ -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;
+150 -186
View File
@@ -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;
+247 -47
View File
@@ -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>
+153 -259
View File
@@ -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>
+70 -61
View File
@@ -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;
}
+172 -187
View File
@@ -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>
+203 -219
View File
@@ -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
View File
@@ -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;
}
+155 -222
View File
@@ -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>
+188 -181
View File
@@ -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;
}
+29 -26
View File
@@ -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 {
+76 -86
View File
@@ -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) {
+17 -17
View File
@@ -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; }
+1 -1
View File
@@ -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);
}
+180 -46
View File
@@ -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>
+7 -7
View File
@@ -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;
+56 -38
View File
@@ -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); }
+35 -31
View File
@@ -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;
+160 -312
View File
@@ -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>
+20 -21
View File
@@ -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;
+5 -5
View File
@@ -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;
+277 -441
View File
@@ -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>
+80 -216
View File
@@ -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 {
+17 -18
View File
@@ -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 {
+3 -3
View File
@@ -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);
}
+10 -10
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+100 -70
View File
@@ -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; }
+431 -431
View File
@@ -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>
+351 -313
View File
@@ -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>
+75 -49
View File
@@ -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;
+41
View File
@@ -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
View File
@@ -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` نکنید.
+26
View File
@@ -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,
},
],
};
+35
View File
@@ -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;
}
}
-26
View File
@@ -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"
}