diff --git a/apps/api/src/automations/automations.module.ts b/apps/api/src/automations/automations.module.ts
index 106d878..9551e10 100644
--- a/apps/api/src/automations/automations.module.ts
+++ b/apps/api/src/automations/automations.module.ts
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module';
+import { SmsService } from '../common/sms.service';
import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service';
@Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController],
- providers: [AutomationsService],
+ providers: [AutomationsService, SmsService],
exports: [AutomationsService],
})
export class AutomationsModule {}
diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts
index 730f0f2..3fff50d 100644
--- a/apps/api/src/automations/automations.service.ts
+++ b/apps/api/src/automations/automations.service.ts
@@ -1,16 +1,34 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+ BadRequestException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
import type {
AutomationAction,
+ AutomationConditionField,
+ AutomationConditionOp,
+ AutomationNotifyChannel,
AutomationRuleConfig,
AutomationRuleDto,
+ AutomationStep,
AutomationTrigger,
CreateAutomationRuleRequest,
UpdateAutomationRuleRequest,
} from '@mento/shared';
+import {
+ MAX_AUTOMATION_STEPS,
+ automationStepToConfig,
+ firstAutomationAction,
+ isAutomationCondition,
+ normalizeAutomationSteps,
+ packAutomationConfig,
+} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
+import { SmsService } from '../common/sms.service';
export type AutomationContext = {
organizationId: string;
@@ -24,12 +42,22 @@ export type AutomationContext = {
assigneeId?: string | null;
};
+type AutomationFacts = {
+ amount: number | null;
+ hasPerson: boolean;
+ hasCompany: boolean;
+ ticketPriority: string | null;
+};
+
@Injectable()
export class AutomationsService {
+ private readonly logger = new Logger(AutomationsService.name);
+
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService,
+ private readonly sms: SmsService,
) {}
async list(organizationId: string): Promise {
@@ -44,14 +72,17 @@ export class AutomationsService {
organizationId: string,
dto: CreateAutomationRuleRequest,
): Promise {
+ const steps = resolveSteps(dto.action, dto.config);
+ const action = firstAutomationAction(steps);
+ if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
const row = await this.prisma.automationRule.create({
data: {
organizationId,
name: dto.name.trim(),
trigger: dto.trigger,
- action: dto.action,
+ action,
isActive: dto.isActive ?? true,
- config: (dto.config ?? {}) as Prisma.InputJsonValue,
+ config: packAutomationConfig(steps) as Prisma.InputJsonValue,
},
});
return this.toDto(row);
@@ -67,15 +98,30 @@ export class AutomationsService {
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
+ const shouldRewriteChain =
+ dto.action !== undefined || dto.config !== undefined;
+ const steps = shouldRewriteChain
+ ? resolveSteps(
+ (dto.action ?? existing.action) as AutomationAction,
+ dto.config ?? asConfig(existing.config),
+ )
+ : null;
+ const nextAction = steps ? firstAutomationAction(steps) : null;
+ if (steps && !nextAction) {
+ throw new BadRequestException('حداقل یک عمل لازم است');
+ }
+
const row = await this.prisma.automationRule.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}),
- ...(dto.action !== undefined ? { action: dto.action } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
- ...(dto.config !== undefined
- ? { config: dto.config as Prisma.InputJsonValue }
+ ...(steps && nextAction
+ ? {
+ action: nextAction,
+ config: packAutomationConfig(steps) as Prisma.InputJsonValue,
+ }
: {}),
},
});
@@ -101,15 +147,53 @@ export class AutomationsService {
trigger,
},
});
+ const facts = await this.loadFacts(ctx);
for (const rule of rules) {
try {
- await this.execute(rule.action as AutomationAction, asConfig(rule.config), ctx);
+ const config = asConfig(rule.config);
+ const steps = normalizeAutomationSteps(
+ rule.action as AutomationAction,
+ config,
+ );
+ for (const step of steps) {
+ if (isAutomationCondition(step)) {
+ if (!matchCondition(step, facts)) break;
+ continue;
+ }
+ if (!step.action) continue;
+ await this.execute(step.action, automationStepToConfig(step), ctx);
+ }
} catch {
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
}
}
}
+ private async loadFacts(ctx: AutomationContext): Promise {
+ if (ctx.entityType === 'deal') {
+ const deal = await this.prisma.deal.findFirst({
+ where: { id: ctx.entityId, organizationId: ctx.organizationId },
+ select: { amount: true, personId: true, companyId: true },
+ });
+ return {
+ amount: deal?.amount != null ? Number(deal.amount) : null,
+ hasPerson: Boolean(deal?.personId ?? ctx.personId),
+ hasCompany: Boolean(deal?.companyId ?? ctx.companyId),
+ ticketPriority: null,
+ };
+ }
+ const ticket = await this.prisma.ticket.findFirst({
+ where: { id: ctx.entityId, organizationId: ctx.organizationId },
+ select: { priority: true, personId: true, companyId: true },
+ });
+ return {
+ amount: null,
+ hasPerson: Boolean(ticket?.personId ?? ctx.personId),
+ hasCompany: Boolean(ticket?.companyId ?? ctx.companyId),
+ ticketPriority: ticket?.priority ?? null,
+ };
+ }
+
private async execute(
action: AutomationAction,
config: AutomationRuleConfig,
@@ -155,25 +239,7 @@ export class AutomationsService {
}
if (action === 'create_notification') {
- const userId =
- config.notifyUserId?.trim() ||
- ctx.assigneeId ||
- ctx.ownerId ||
- null;
- if (!userId) return;
- await this.notifications.create({
- organizationId: ctx.organizationId,
- userId,
- type: 'automation',
- title,
- body: config.body?.trim() || ctx.title,
- link:
- ctx.entityType === 'deal'
- ? `/deals/${ctx.entityId}`
- : `/tickets/${ctx.entityId}`,
- entityType: ctx.entityType,
- entityId: ctx.entityId,
- });
+ await this.executeNotify(title, config, ctx);
return;
}
@@ -188,6 +254,83 @@ export class AutomationsService {
}
}
+ private async executeNotify(
+ title: string,
+ config: AutomationRuleConfig,
+ ctx: AutomationContext,
+ ) {
+ const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
+ const wantApp = channel === 'app' || channel === 'both';
+ const wantSms = channel === 'sms' || channel === 'both';
+ const smsText = (config.body?.trim() || title).slice(0, 160);
+ const link =
+ ctx.entityType === 'deal'
+ ? `/deals/${ctx.entityId}`
+ : `/tickets/${ctx.entityId}`;
+
+ if (config.notifyTarget === 'person') {
+ if (wantSms) {
+ const person = ctx.personId
+ ? await this.prisma.person.findFirst({
+ where: {
+ id: ctx.personId,
+ organizationId: ctx.organizationId,
+ },
+ select: { mobile: true, phone: true },
+ })
+ : null;
+ await this.sendAutomationSms(person?.mobile || person?.phone, smsText);
+ }
+ return;
+ }
+
+ const userId =
+ config.notifyUserId?.trim() ||
+ ctx.assigneeId ||
+ ctx.ownerId ||
+ null;
+ if (!userId) return;
+
+ if (wantApp) {
+ await this.notifications.create({
+ organizationId: ctx.organizationId,
+ userId,
+ type: 'automation',
+ title,
+ body: config.body?.trim() || ctx.title,
+ link,
+ entityType: ctx.entityType,
+ entityId: ctx.entityId,
+ });
+ }
+
+ if (wantSms) {
+ const user = await this.prisma.user.findFirst({
+ where: { id: userId, organizationId: ctx.organizationId },
+ select: { mobile: true },
+ });
+ await this.sendAutomationSms(user?.mobile, smsText);
+ }
+ }
+
+ private async sendAutomationSms(
+ mobile: string | null | undefined,
+ text: string,
+ ) {
+ if (!mobile) {
+ this.logger.warn('Automation SMS skipped: no mobile');
+ return;
+ }
+ if (!this.sms.isConfigured()) {
+ this.logger.warn('Automation SMS skipped: SMS not configured');
+ return;
+ }
+ const result = await this.sms.sendSmsDetailed(mobile, text);
+ if (!result.ok) {
+ this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
+ }
+ }
+
private toDto(row: {
id: string;
name: string;
@@ -216,6 +359,55 @@ function asConfig(raw: unknown): AutomationRuleConfig {
return raw as AutomationRuleConfig;
}
+function resolveSteps(
+ action: AutomationAction,
+ config?: AutomationRuleConfig,
+): AutomationStep[] {
+ const steps = normalizeAutomationSteps(action, config);
+ if (!firstAutomationAction(steps)) {
+ throw new BadRequestException('حداقل یک عمل لازم است');
+ }
+ if (steps.length > MAX_AUTOMATION_STEPS) {
+ throw new BadRequestException(
+ `حداکثر ${MAX_AUTOMATION_STEPS} عمل در یک قانون مجاز است`,
+ );
+ }
+ return steps;
+}
+
+function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
+ const field: AutomationConditionField = step.conditionField ?? 'has_person';
+ const op: AutomationConditionOp = step.conditionOp ?? 'is_set';
+
+ if (field === 'deal_amount') {
+ const amount = facts.amount;
+ const value = Number(step.conditionValue);
+ if (amount == null || !Number.isFinite(value)) return false;
+ if (op === 'gte') return amount >= value;
+ if (op === 'lte') return amount <= value;
+ if (op === 'eq') return amount === value;
+ if (op === 'neq') return amount !== value;
+ return false;
+ }
+
+ if (field === 'has_person') {
+ return op === 'is_empty' ? !facts.hasPerson : facts.hasPerson;
+ }
+
+ if (field === 'has_company') {
+ return op === 'is_empty' ? !facts.hasCompany : facts.hasCompany;
+ }
+
+ if (field === 'ticket_priority') {
+ const current = facts.ticketPriority;
+ const value = String(step.conditionValue ?? '');
+ if (op === 'neq') return current !== value;
+ return current === value;
+ }
+
+ return false;
+}
+
function defaultTitle(action: AutomationAction, entityTitle: string) {
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
diff --git a/apps/web/src/components/automations/AutomationCanvas.vue b/apps/web/src/components/automations/AutomationCanvas.vue
new file mode 100644
index 0000000..9000d71
--- /dev/null
+++ b/apps/web/src/components/automations/AutomationCanvas.vue
@@ -0,0 +1,551 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
گره بعدی را انتخاب کنید
+
+
+
+
+
+
+
+
+ حداکثر {{ toFaDigits(MAX_AUTOMATION_STEPS) }} گره در یک قانون
+
+
+
+
+
diff --git a/apps/web/src/components/automations/AutomationInspector.vue b/apps/web/src/components/automations/AutomationInspector.vue
new file mode 100644
index 0000000..5e0dc5d
--- /dev/null
+++ b/apps/web/src/components/automations/AutomationInspector.vue
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/pipeline/AddDealButton.vue b/apps/web/src/components/pipeline/AddDealButton.vue
index caa3504..1149565 100644
--- a/apps/web/src/components/pipeline/AddDealButton.vue
+++ b/apps/web/src/components/pipeline/AddDealButton.vue
@@ -18,7 +18,7 @@ const emit = defineEmits<{
class="add-deal"
:style="{
'--add-color': PIPELINE_ACCENT[accent].text,
- '--add-border': PIPELINE_ACCENT[accent].border,
+ '--add-border': PIPELINE_ACCENT[accent].solid,
'--add-soft': PIPELINE_ACCENT[accent].soft,
}"
aria-label="افزودن معامله"
@@ -34,16 +34,17 @@ const emit = defineEmits<{
display: flex;
align-items: center;
justify-content: center;
- gap: 0.35rem;
+ gap: 0.4rem;
width: 100%;
- margin-top: 0.35rem;
- padding: 0.85rem 1rem;
+ margin-top: 0.15rem;
+ padding: 0.9rem 1rem;
border: 1.5px dashed var(--add-border);
border-radius: 16px;
- background: color-mix(in srgb, var(--add-soft) 55%, white);
+ background: #fff;
color: var(--add-color);
font-weight: 700;
font-size: 0.9rem;
+ white-space: nowrap;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue
index 54cf394..cc3f685 100644
--- a/apps/web/src/components/pipeline/DealCard.vue
+++ b/apps/web/src/components/pipeline/DealCard.vue
@@ -43,7 +43,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
{{ deal.initials }}
{{ deal.title }}
-
{{ deal.description }}
+
{{ deal.description }}
-
- {{ money.number }}
- {{ money.unit }}
-
-
-
@@ -37,7 +50,7 @@ const emit = defineEmits<{
@click="emit('update:viewMode', 'kanban')"
>
- نمای کاریز
+ نمای کاریز
@@ -55,12 +68,7 @@ const emit = defineEmits<{
انتخاب کاریز
@@ -94,17 +103,20 @@ const emit = defineEmits<{
diff --git a/apps/web/src/views/AutomationsSettingsView.vue b/apps/web/src/views/AutomationsSettingsView.vue
index 47becda..f52a9f1 100644
--- a/apps/web/src/views/AutomationsSettingsView.vue
+++ b/apps/web/src/views/AutomationsSettingsView.vue
@@ -1,86 +1,202 @@
-
اگر / آنگاه
+
اتوماسیون
- بعد از بردن معامله یا حل شدن تیکت، فعالیت، اعلان یا امتیاز باشگاه بسازید.
+ مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید.
-
+
+
+
+
{{ error }}
{{ ok }}
-
+
+
-
- قوانین
-
- هنوز قانونی نیست.
-
+
+
@@ -243,9 +360,7 @@ onMounted(load);
.page {
display: grid;
gap: 1rem;
- max-width: 860px;
min-width: 0;
- overflow-x: clip;
}
.page-head {
@@ -260,6 +375,12 @@ onMounted(load);
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);
@@ -272,57 +393,147 @@ h1 {
color: #64748b;
}
-.panel {
+.workspace {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.rules,
+.studio {
border: 1px solid #e2e8f0;
border-radius: 18px;
- padding: 1.1rem;
background: #fff;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
+}
+
+.rules {
+ padding: 1rem;
display: grid;
- gap: 0.75rem;
+ gap: 0.7rem;
+ align-content: start;
}
h2 {
margin: 0;
- font-size: 1.05rem;
- font-weight: 700;
- color: #0f172a;
+ font-size: 1rem;
+ font-weight: 800;
}
-.form label {
+.rules ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0.45rem;
+}
+
+.rules li {
+ border: 1px solid #e8eef3;
+ border-radius: 14px;
+ padding: 0.55rem 0.65rem 0.45rem;
display: grid;
gap: 0.35rem;
- font-size: 0.9rem;
- color: #64748b;
}
-input,
-select,
-textarea {
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- padding: 0.6rem 0.75rem;
- font: inherit;
- color: #0f172a;
- background: #fff;
+.rules li.on {
+ border-color: #99f6e4;
+ background: #f0fdfa;
}
-.grid2 {
+.pick {
display: grid;
- grid-template-columns: 1fr 1fr;
+ gap: 0.15rem;
+ text-align: start;
+ border: 0;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+}
+
+.pick strong {
+ font-size: 0.9rem;
+}
+
+.pick span {
+ color: #64748b;
+ font-size: 0.75rem;
+}
+
+.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;
+}
+
+.studio {
+ padding: 0.85rem;
+ display: grid;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.7rem;
+ align-items: end;
+}
+
+.name {
+ display: grid;
+ gap: 0.3rem;
+ font-size: 0.82rem;
+ color: #64748b;
+ flex: 1 1 14rem;
+}
+
+.name input {
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 0.55rem 0.7rem;
+ font: inherit;
+ color: #0f172a;
+}
+
.check {
- display: flex !important;
+ display: flex;
align-items: center;
- gap: 0.45rem;
+ gap: 0.4rem;
+ color: #334155;
+ font-size: 0.88rem;
+ padding-bottom: 0.45rem;
+}
+
+.stage {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+ min-width: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
+ gap: 0.35rem;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
@@ -338,49 +549,15 @@ textarea {
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;
- box-shadow: none;
-}
-
-.actions,
-.row-actions {
- display: flex;
- gap: 0.55rem;
- flex-wrap: wrap;
-}
-
-.list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: grid;
- gap: 0.7rem;
-}
-
-.list li {
- display: flex;
- justify-content: space-between;
- gap: 0.75rem;
- flex-wrap: wrap;
- border-bottom: 1px solid #e2e8f0;
- padding-bottom: 0.65rem;
-}
-
-.link {
- background: none;
- border: none;
- color: #0f766e;
- cursor: pointer;
- font: inherit;
- padding: 0;
- font-weight: 600;
-}
-
-.danger {
- color: #b91c1c;
}
.error {
@@ -394,16 +571,26 @@ textarea {
font-weight: 600;
}
-@media (max-width: 700px) {
- .grid2 {
- grid-template-columns: 1fr;
+@media (min-width: 1280px) {
+ .workspace {
+ grid-template-columns: minmax(15rem, 17.5rem) minmax(0, 1fr);
}
- .actions,
- .row-actions,
- .list li {
- flex-direction: column;
- align-items: stretch;
+ .stage {
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 19rem);
+ }
+}
+
+@media (max-width: 1279px) {
+ .rules ul {
+ display: flex;
+ overflow-x: auto;
+ gap: 0.5rem;
+ }
+
+ .rules li {
+ min-width: 13rem;
+ flex: 0 0 auto;
}
}
diff --git a/apps/web/src/views/InvoicesView.vue b/apps/web/src/views/InvoicesView.vue
index e97b59f..1047597 100644
--- a/apps/web/src/views/InvoicesView.vue
+++ b/apps/web/src/views/InvoicesView.vue
@@ -303,9 +303,9 @@ async function load() {
closeMenus();
try {
const [invRes, quoteRes, peopleRes] = await Promise.all([
- api.get>('/invoices', { params: { limit: 200 } }),
- api.get>('/quotes', { params: { limit: 200 } }),
- api.get>('/people', { params: { limit: 200 } }),
+ api.get>('/invoices', { params: { limit: 100 } }),
+ api.get>('/quotes', { params: { limit: 100 } }),
+ api.get>('/people', { params: { limit: 100 } }),
]);
invoices.value = invRes.data.items;
quotes.value = quoteRes.data.items;
diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue
index 3b7b110..253845e 100644
--- a/apps/web/src/views/PipelineBoardView.vue
+++ b/apps/web/src/views/PipelineBoardView.vue
@@ -7,6 +7,7 @@ import { formatJalaliNumeric } from '@/lib/jalali';
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
+import PipelineList from '@/components/pipeline/PipelineList.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import type { PipelineDeal, PipelineStage } from '@/lib/pipeline-board';
import {
@@ -45,7 +46,7 @@ const moving = ref(false);
const pipelineOptions = computed(() =>
pipelines.value.map((p) => ({
id: p.id,
- name: p.isDefault ? `${p.name} (پیشفرض)` : p.name,
+ name: p.name,
})),
);
@@ -264,25 +265,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
:selected-pipeline-id="selectedPipelineId"
:view-mode="viewMode"
:search="search"
+ :can-create="canCreatePipeline"
@update:view-mode="viewMode = $event"
@update:search="search = $event"
@update:selected-pipeline-id="selectedPipelineId = $event"
@filter="onFilter"
+ @create="showCreate = !showCreate"
/>
-
-
-
-
-
در حال بارگذاری کاریز…
-
- نمای لیست معاملات
-
+
.page {
display: grid;
- gap: 0.55rem;
+ gap: 0.15rem;
min-width: 0;
+ max-width: 100%;
direction: rtl;
text-align: right;
-}
-
-.create-row {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
+ container-type: inline-size;
+ container-name: pipeline;
}
.btn-ghost-sm {
@@ -373,10 +363,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
+ min-width: 0;
}
.create-box input {
flex: 1 1 180px;
+ min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
@@ -384,21 +376,28 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
font: inherit;
}
-.list-placeholder {
- margin-top: 0.5rem;
- padding: 2.5rem 1rem;
- text-align: center;
- color: var(--muted);
- background: var(--surface);
- border: 1px dashed var(--line);
- border-radius: var(--radius);
- font-weight: 700;
+@container pipeline (max-width: 560px) {
+ .create-box {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .create-box input,
+ .create-box .btn,
+ .create-box .btn-ghost-sm {
+ width: 100%;
+ }
}
.muted {
color: var(--muted);
}
+.error {
+ color: var(--danger);
+ font-weight: 600;
+}
+
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 0d72696..eb364a3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1739,13 +1739,123 @@ export const AUTOMATION_ACTION_LABELS: Record = {
add_loyalty_points: "افزودن امتیاز باشگاه",
};
+export const MAX_AUTOMATION_STEPS = 8;
+
+export const AUTOMATION_ACTIONS = [
+ "create_activity",
+ "create_notification",
+ "add_loyalty_points",
+] as const satisfies readonly AutomationAction[];
+
+export const AUTOMATION_TRIGGERS = [
+ "deal_won",
+ "ticket_resolved",
+] as const satisfies readonly AutomationTrigger[];
+
+export type AutomationStepKind = "action" | "condition";
+
+export type AutomationConditionField =
+ | "deal_amount"
+ | "has_person"
+ | "has_company"
+ | "ticket_priority";
+
+export type AutomationConditionOp =
+ | "gte"
+ | "lte"
+ | "eq"
+ | "neq"
+ | "is_set"
+ | "is_empty";
+
+export type AutomationNotifyChannel = "app" | "sms" | "both";
+export type AutomationNotifyTarget = "staff" | "person";
+
+export const AUTOMATION_CONDITION_FIELD_LABELS: Record<
+ AutomationConditionField,
+ string
+> = {
+ deal_amount: "مبلغ معامله",
+ has_person: "مخاطب دارد",
+ has_company: "شرکت دارد",
+ ticket_priority: "اولویت تیکت",
+};
+
+export const AUTOMATION_CONDITION_OP_LABELS: Record<
+ AutomationConditionOp,
+ string
+> = {
+ gte: "بزرگتر یا مساوی",
+ lte: "کوچکتر یا مساوی",
+ eq: "مساوی",
+ neq: "نامساوی",
+ is_set: "دارد",
+ is_empty: "ندارد",
+};
+
+export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record<
+ AutomationNotifyChannel,
+ string
+> = {
+ app: "ناتیفیکیشن داخلی",
+ sms: "پیامک",
+ both: "هر دو",
+};
+
+export const AUTOMATION_NOTIFY_CHANNELS = [
+ "app",
+ "sms",
+ "both",
+] as const satisfies readonly AutomationNotifyChannel[];
+
+export function isAutomationCondition(step: Pick): boolean {
+ return step.kind === "condition";
+}
+
+export function firstAutomationAction(
+ steps: AutomationStep[],
+): AutomationAction | null {
+ const step = steps.find((s) => !isAutomationCondition(s) && s.action);
+ return step?.action ?? null;
+}
+
+export function conditionFieldsForTrigger(
+ trigger: AutomationTrigger,
+): AutomationConditionField[] {
+ if (trigger === "deal_won") {
+ return ["deal_amount", "has_person", "has_company"];
+ }
+ return ["ticket_priority", "has_person", "has_company"];
+}
+
+export interface AutomationStep {
+ id: string;
+ kind?: AutomationStepKind;
+ action?: AutomationAction;
+ title?: string;
+ body?: string;
+ assigneeId?: string | null;
+ notifyUserId?: string | null;
+ notifyChannel?: AutomationNotifyChannel;
+ notifyTarget?: AutomationNotifyTarget;
+ activityType?: "call" | "meeting" | "task" | "note";
+ points?: number;
+ conditionField?: AutomationConditionField;
+ conditionOp?: AutomationConditionOp;
+ conditionValue?: string | number;
+}
+
export interface AutomationRuleConfig {
title?: string;
body?: string;
assigneeId?: string | null;
notifyUserId?: string | null;
+ notifyChannel?: AutomationNotifyChannel;
+ notifyTarget?: AutomationNotifyTarget;
activityType?: "call" | "meeting" | "task" | "note";
points?: number;
+ /** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده میشود */
+ steps?: AutomationStep[];
}
export interface AutomationRuleDto {
@@ -1769,6 +1879,77 @@ export interface CreateAutomationRuleRequest {
export type UpdateAutomationRuleRequest = Partial;
+export function normalizeAutomationSteps(
+ action: AutomationAction,
+ config?: AutomationRuleConfig | null,
+): AutomationStep[] {
+ const cfg = config ?? {};
+ if (Array.isArray(cfg.steps) && cfg.steps.length) {
+ return cfg.steps
+ .filter((s) => s && (s.kind === "condition" || s.action))
+ .map((s, i) => ({
+ id: s.id || `step-${i}`,
+ kind: s.kind === "condition" ? "condition" : "action",
+ action: s.action,
+ title: s.title,
+ body: s.body,
+ assigneeId: s.assigneeId,
+ notifyUserId: s.notifyUserId,
+ notifyChannel: s.notifyChannel,
+ notifyTarget: s.notifyTarget,
+ activityType: s.activityType,
+ points: s.points,
+ conditionField: s.conditionField,
+ conditionOp: s.conditionOp,
+ conditionValue: s.conditionValue,
+ }));
+ }
+ return [
+ {
+ id: "step-0",
+ kind: "action",
+ action,
+ title: cfg.title,
+ body: cfg.body,
+ assigneeId: cfg.assigneeId,
+ notifyUserId: cfg.notifyUserId,
+ notifyChannel: cfg.notifyChannel,
+ notifyTarget: cfg.notifyTarget,
+ activityType: cfg.activityType,
+ points: cfg.points,
+ },
+ ];
+}
+
+export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleConfig {
+ const first =
+ steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0];
+ return {
+ steps,
+ title: first?.title,
+ body: first?.body,
+ assigneeId: first?.assigneeId,
+ notifyUserId: first?.notifyUserId,
+ notifyChannel: first?.notifyChannel,
+ notifyTarget: first?.notifyTarget,
+ activityType: first?.activityType,
+ points: first?.points,
+ };
+}
+
+export function automationStepToConfig(step: AutomationStep): AutomationRuleConfig {
+ return {
+ title: step.title,
+ body: step.body,
+ assigneeId: step.assigneeId,
+ notifyUserId: step.notifyUserId,
+ notifyChannel: step.notifyChannel,
+ notifyTarget: step.notifyTarget,
+ activityType: step.activityType,
+ points: step.points,
+ };
+}
+
// —— تهحساب / حسابداری ——
export const TAHSAB_SANAD_TYPES = [