feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts"
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"levels" JSONB NOT NULL DEFAULT '[]',
|
||||
"monthlyGoal" INTEGER NOT NULL DEFAULT 1500,
|
||||
"showFullLeaderboard" BOOLEAN NOT NULL DEFAULT true,
|
||||
"enabledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"event" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"conditions" JSONB NOT NULL DEFAULT '[]',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"maxPerDay" INTEGER,
|
||||
"maxCoinsPerDay" INTEGER,
|
||||
"maxPerUser" INTEGER,
|
||||
"cooldownMinutes" INTEGER,
|
||||
"oncePerEntity" BOOLEAN NOT NULL DEFAULT false,
|
||||
"firstTimeOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
|
||||
"roles" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"teamIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"ruleId" TEXT,
|
||||
"bonusId" TEXT,
|
||||
"badgeId" TEXT,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'approved',
|
||||
"title" TEXT NOT NULL,
|
||||
"entityType" TEXT,
|
||||
"entityId" TEXT,
|
||||
"entityTitle" TEXT,
|
||||
"idempotencyKey" TEXT,
|
||||
"reason" TEXT,
|
||||
"createdById" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GamificationTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationWallet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"coinsEarned" INTEGER NOT NULL DEFAULT 0,
|
||||
"bestMonthScore" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationWallet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationReward" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"cost" INTEGER NOT NULL,
|
||||
"icon" TEXT NOT NULL DEFAULT 'gift',
|
||||
"color" TEXT NOT NULL DEFAULT '#14b8a6',
|
||||
"stock" INTEGER,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"requiresApproval" BOOLEAN NOT NULL DEFAULT true,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationReward_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationRedemption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rewardId" TEXT NOT NULL,
|
||||
"cost" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"note" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationRedemption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationBadge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"icon" TEXT NOT NULL DEFAULT 'medal',
|
||||
"color" TEXT NOT NULL DEFAULT '#f59e0b',
|
||||
"metric" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"threshold" INTEGER NOT NULL,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationBadge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationUserBadge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"badgeId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GamificationUserBadge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationBonus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"kind" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"threshold" INTEGER NOT NULL DEFAULT 1,
|
||||
"period" TEXT NOT NULL DEFAULT 'month',
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationBonus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationSettings_organizationId_key" ON "GamificationSettings"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRule_organizationId_event_isActive_idx" ON "GamificationRule"("organizationId", "event", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_createdAt_idx" ON "GamificationTransaction"("organizationId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_userId_createdAt_idx" ON "GamificationTransaction"("organizationId", "userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_ruleId_userId_create_idx" ON "GamificationTransaction"("organizationId", "ruleId", "userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_status_idx" ON "GamificationTransaction"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationTransaction_organizationId_idempotencyKey_key" ON "GamificationTransaction"("organizationId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationWallet_organizationId_score_idx" ON "GamificationWallet"("organizationId", "score");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationWallet_organizationId_userId_key" ON "GamificationWallet"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationReward_organizationId_isActive_idx" ON "GamificationReward"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRedemption_organizationId_status_idx" ON "GamificationRedemption"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRedemption_organizationId_userId_idx" ON "GamificationRedemption"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationBadge_organizationId_isActive_idx" ON "GamificationBadge"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationUserBadge_organizationId_userId_idx" ON "GamificationUserBadge"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationUserBadge_badgeId_userId_key" ON "GamificationUserBadge"("badgeId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationBonus_organizationId_isActive_idx" ON "GamificationBonus"("organizationId", "isActive");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationSettings" ADD CONSTRAINT "GamificationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRule" ADD CONSTRAINT "GamificationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationTransaction" ADD CONSTRAINT "GamificationTransaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationWallet" ADD CONSTRAINT "GamificationWallet_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationReward" ADD CONSTRAINT "GamificationReward_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_rewardId_fkey" FOREIGN KEY ("rewardId") REFERENCES "GamificationReward"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationBadge" ADD CONSTRAINT "GamificationBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "GamificationBadge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationBonus" ADD CONSTRAINT "GamificationBonus_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Enable the new module for orgs with an explicit module list
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = array_append("enabledModules", 'gamification')
|
||||
WHERE cardinality("enabledModules") > 0 AND NOT ('gamification' = ANY("enabledModules"));
|
||||
+987
-785
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@ import {
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
import {
|
||||
@@ -96,6 +98,7 @@ export class ActivitiesService {
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly stages: ActivityStagesService,
|
||||
private readonly types: ActivityTypesService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||
@@ -429,6 +432,22 @@ export class ActivitiesService {
|
||||
assigneeId: row.assignee?.id ?? null,
|
||||
ownerId: row.assignee?.id ?? null,
|
||||
});
|
||||
const gameBase = {
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: {
|
||||
activityType: row.type,
|
||||
typeName: row.typeDef?.name ?? null,
|
||||
priority: row.priority,
|
||||
hasDescription: !!row.description?.trim(),
|
||||
},
|
||||
};
|
||||
this.game.emit({ ...gameBase, event: 'ACTIVITY_CREATED' });
|
||||
if (row.type === 'call') this.game.emit({ ...gameBase, event: 'CALL_LOGGED' });
|
||||
if (row.type === 'meeting') this.game.emit({ ...gameBase, event: 'MEETING_CREATED' });
|
||||
if (initialStage?.kind === 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
@@ -708,8 +727,26 @@ export class ActivitiesService {
|
||||
company: { id: string } | null;
|
||||
deal: { id: string } | null;
|
||||
assignee: { id: string } | null;
|
||||
type: string;
|
||||
priority: string;
|
||||
dueAt: Date | null;
|
||||
typeDef?: { name: string } | null;
|
||||
},
|
||||
) {
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId: row.assignee?.id,
|
||||
event: 'TASK_COMPLETED',
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: {
|
||||
activityType: row.type,
|
||||
typeName: row.typeDef?.name ?? null,
|
||||
priority: row.priority,
|
||||
onTime: !row.dueAt || tehranDayKey() <= tehranDayKey(row.dueAt),
|
||||
},
|
||||
});
|
||||
const ctx = {
|
||||
organizationId,
|
||||
entityType: 'activity' as const,
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ReservationsModule } from './reservations/reservations.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversationsModule } from './conversations/conversations.module';
|
||||
import { RoutinesModule } from './routines/routines.module';
|
||||
import { GamificationModule } from './gamification/gamification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -103,6 +104,7 @@ import { RoutinesModule } from './routines/routines.module';
|
||||
ReservationsModule,
|
||||
ConversationsModule,
|
||||
RoutinesModule,
|
||||
GamificationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
function parseDateOrNull(raw?: string | null): Date | null {
|
||||
if (!raw?.trim()) return null;
|
||||
@@ -70,7 +71,10 @@ type CompanyRow = {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
@@ -157,6 +161,16 @@ export class CompaniesService {
|
||||
data,
|
||||
include: companyInclude,
|
||||
});
|
||||
if (row.importSource === 'manual') {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'COMPANY_CREATED',
|
||||
entityType: 'company',
|
||||
entityId: row.id,
|
||||
entityTitle: row.name,
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import {
|
||||
@@ -90,6 +91,7 @@ export class PeopleService {
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -226,6 +228,17 @@ export class PeopleService {
|
||||
};
|
||||
await this.automations.runTrigger('person_created', createdCtx);
|
||||
await this.messageAutomations.onEvent('person_created', createdCtx);
|
||||
if (row.importSource === 'manual') {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'CUSTOMER_CREATED',
|
||||
entityType: 'person',
|
||||
entityId: row.id,
|
||||
entityTitle: createdCtx.title,
|
||||
facts: { source: row.importSource },
|
||||
});
|
||||
}
|
||||
this.webhooks.emit(actor.organizationId, 'person.created', {
|
||||
id: row.id,
|
||||
fullName: createdCtx.title,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { GamificationCondition, GamificationLevelDef } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENT_KEYS } from '@mento/shared';
|
||||
|
||||
const nullable = () => ValidateIf((_, v) => v !== '' && v != null);
|
||||
|
||||
export class SaveRuleDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event!: (typeof GAMIFICATION_EVENT_KEYS)[number];
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(-10000)
|
||||
@Max(100000)
|
||||
score!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
coins!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
conditions?: GamificationCondition[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxPerDay?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxCoinsPerDay?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxPerUser?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100000)
|
||||
cooldownMinutes?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
oncePerEntity?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
firstTimeOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresApproval?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
roles?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
teamIds?: string[];
|
||||
}
|
||||
|
||||
export class ToggleDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class AdjustDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(-1000000)
|
||||
@Max(1000000)
|
||||
amount!: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecisionDto {
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class RedeemDto {
|
||||
@IsString()
|
||||
rewardId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class SaveRewardDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000000)
|
||||
cost!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stock?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresApproval?: boolean;
|
||||
}
|
||||
|
||||
export class SaveBadgeDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsIn(['event_count', 'score_total', 'routine_streak', 'single_deal_amount'])
|
||||
metric!: 'event_count' | 'score_total' | 'routine_streak' | 'single_deal_amount';
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event?: (typeof GAMIFICATION_EVENT_KEYS)[number] | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
threshold!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
coins?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SaveBonusDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsIn(['event_count_period', 'routine_streak', 'top_of_month'])
|
||||
kind!: 'event_count_period' | 'routine_streak' | 'top_of_month';
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event?: (typeof GAMIFICATION_EVENT_KEYS)[number] | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
threshold!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'week', 'month'])
|
||||
period?: 'day' | 'week' | 'month';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
score?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
coins?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
levels?: GamificationLevelDef[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000000)
|
||||
monthlyGoal?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFullLeaderboard?: boolean;
|
||||
}
|
||||
|
||||
export class TxQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
kind?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
event?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { GamificationAdminService } from './gamification-admin.service';
|
||||
import {
|
||||
AdjustDto,
|
||||
DecisionDto,
|
||||
SaveBadgeDto,
|
||||
SaveBonusDto,
|
||||
SaveRewardDto,
|
||||
SaveRuleDto,
|
||||
ToggleDto,
|
||||
UpdateSettingsDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('admin/gamification')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('gamification')
|
||||
export class GamificationAdminController {
|
||||
constructor(private readonly admin: GamificationAdminService) {}
|
||||
|
||||
/* قوانین */
|
||||
|
||||
@Get('rules')
|
||||
rules(@CurrentUser() user: Actor) {
|
||||
return this.admin.listRules(user);
|
||||
}
|
||||
|
||||
@Post('rules')
|
||||
createRule(@CurrentUser() user: Actor, @Body() dto: SaveRuleDto) {
|
||||
return this.admin.createRule(user, dto);
|
||||
}
|
||||
|
||||
@Patch('rules/:id')
|
||||
updateRule(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRuleDto) {
|
||||
return this.admin.updateRule(user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('rules/:id/toggle')
|
||||
toggleRule(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: ToggleDto) {
|
||||
return this.admin.toggleRule(user, id, dto.isActive);
|
||||
}
|
||||
|
||||
@Post('rules/:id/duplicate')
|
||||
duplicateRule(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.duplicateRule(user, id);
|
||||
}
|
||||
|
||||
@Delete('rules/:id')
|
||||
deleteRule(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteRule(user, id);
|
||||
}
|
||||
|
||||
/* تنظیم دستی */
|
||||
|
||||
@Post('adjust-score')
|
||||
adjustScore(@CurrentUser() user: Actor, @Body() dto: AdjustDto) {
|
||||
return this.admin.adjust(user, 'score', dto);
|
||||
}
|
||||
|
||||
@Post('adjust-coins')
|
||||
adjustCoins(@CurrentUser() user: Actor, @Body() dto: AdjustDto) {
|
||||
return this.admin.adjust(user, 'coins', dto);
|
||||
}
|
||||
|
||||
/* تأیید امتیاز */
|
||||
|
||||
@Get('pending')
|
||||
pending(@CurrentUser() user: Actor) {
|
||||
return this.admin.pending(user);
|
||||
}
|
||||
|
||||
@Post('transactions/:id/approve')
|
||||
approveTx(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideTx(user, id, true, dto);
|
||||
}
|
||||
|
||||
@Post('transactions/:id/reject')
|
||||
rejectTx(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideTx(user, id, false, dto);
|
||||
}
|
||||
|
||||
/* درخواستهای پاداش */
|
||||
|
||||
@Get('redemptions')
|
||||
redemptions(@CurrentUser() user: Actor, @Query('status') status?: string) {
|
||||
return this.admin.redemptions(user, status);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/approve')
|
||||
approveRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'approve', dto);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/reject')
|
||||
rejectRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'reject', dto);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/deliver')
|
||||
deliverRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'deliver', dto);
|
||||
}
|
||||
|
||||
/* پاداشها */
|
||||
|
||||
@Get('rewards')
|
||||
rewards(@CurrentUser() user: Actor) {
|
||||
return this.admin.listRewards(user);
|
||||
}
|
||||
|
||||
@Post('rewards')
|
||||
createReward(@CurrentUser() user: Actor, @Body() dto: SaveRewardDto) {
|
||||
return this.admin.createReward(user, dto);
|
||||
}
|
||||
|
||||
@Patch('rewards/:id')
|
||||
updateReward(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRewardDto) {
|
||||
return this.admin.updateReward(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('rewards/:id')
|
||||
deleteReward(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteReward(user, id);
|
||||
}
|
||||
|
||||
/* نشانها */
|
||||
|
||||
@Get('badges')
|
||||
badges(@CurrentUser() user: Actor) {
|
||||
return this.admin.listBadges(user);
|
||||
}
|
||||
|
||||
@Post('badges')
|
||||
createBadge(@CurrentUser() user: Actor, @Body() dto: SaveBadgeDto) {
|
||||
return this.admin.createBadge(user, dto);
|
||||
}
|
||||
|
||||
@Patch('badges/:id')
|
||||
updateBadge(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveBadgeDto) {
|
||||
return this.admin.updateBadge(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('badges/:id')
|
||||
deleteBadge(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteBadge(user, id);
|
||||
}
|
||||
|
||||
/* بونوسها */
|
||||
|
||||
@Get('bonuses')
|
||||
bonuses(@CurrentUser() user: Actor) {
|
||||
return this.admin.listBonuses(user);
|
||||
}
|
||||
|
||||
@Post('bonuses')
|
||||
createBonus(@CurrentUser() user: Actor, @Body() dto: SaveBonusDto) {
|
||||
return this.admin.createBonus(user, dto);
|
||||
}
|
||||
|
||||
@Patch('bonuses/:id')
|
||||
updateBonus(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveBonusDto) {
|
||||
return this.admin.updateBonus(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('bonuses/:id')
|
||||
deleteBonus(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteBonus(user, id);
|
||||
}
|
||||
|
||||
/* تنظیمات و گزارش */
|
||||
|
||||
@Get('settings')
|
||||
settings(@CurrentUser() user: Actor) {
|
||||
return this.admin.settings(user);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
updateSettings(@CurrentUser() user: Actor, @Body() dto: UpdateSettingsDto) {
|
||||
return this.admin.updateSettings(user, dto);
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
reports(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.admin.report(user, period);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
GamificationBadgeDto,
|
||||
GamificationBadgeMetric,
|
||||
GamificationBonusDto,
|
||||
GamificationBonusKind,
|
||||
GamificationRedemptionDto,
|
||||
GamificationReportDto,
|
||||
GamificationReportUserRow,
|
||||
GamificationRewardDto,
|
||||
GamificationRoiMetric,
|
||||
GamificationRuleDto,
|
||||
GamificationSettingsDto,
|
||||
GamificationTxDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
GAMIFICATION_BADGE_METRICS,
|
||||
GAMIFICATION_BONUS_KINDS,
|
||||
GAMIFICATION_EVENTS,
|
||||
isGamificationEvent,
|
||||
} from '@mento/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { type Actor, elevated } from '../common/record-scope';
|
||||
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
|
||||
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
|
||||
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
|
||||
import type {
|
||||
AdjustDto,
|
||||
DecisionDto,
|
||||
SaveBadgeDto,
|
||||
SaveBonusDto,
|
||||
SaveRewardDto,
|
||||
SaveRuleDto,
|
||||
UpdateSettingsDto,
|
||||
} from './dto';
|
||||
|
||||
const ROLES = ['owner', 'admin', 'sales', 'support'];
|
||||
const ROI_WINDOW_DAYS = 30;
|
||||
|
||||
function optInt(v: number | null | undefined): number | null {
|
||||
if (v == null) return null;
|
||||
const n = Math.floor(Number(v));
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GamificationAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async assertAdmin(actor: Actor) {
|
||||
if (!elevated(actor.role)) {
|
||||
throw new ForbiddenException('فقط مدیر سیستم به این بخش دسترسی دارد');
|
||||
}
|
||||
await this.game.requireEnabled(actor);
|
||||
}
|
||||
|
||||
/* ───────── قوانین ───────── */
|
||||
|
||||
private toRuleDto = toRuleDto;
|
||||
|
||||
private ruleData(dto: SaveRuleDto) {
|
||||
if (!isGamificationEvent(dto.event)) throw new BadRequestException('رویداد نامعتبر است');
|
||||
if (!dto.score && !dto.coins) throw new BadRequestException('امتیاز یا سکه باید بیشتر از صفر باشد');
|
||||
return {
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
event: dto.event,
|
||||
score: Math.trunc(dto.score),
|
||||
coins: Math.trunc(dto.coins),
|
||||
conditions: sanitizeConditions(dto.conditions) as unknown as Prisma.InputJsonValue,
|
||||
isActive: dto.isActive ?? true,
|
||||
maxPerDay: optInt(dto.maxPerDay),
|
||||
maxCoinsPerDay: optInt(dto.maxCoinsPerDay),
|
||||
maxPerUser: optInt(dto.maxPerUser),
|
||||
cooldownMinutes: optInt(dto.cooldownMinutes),
|
||||
oncePerEntity: dto.oncePerEntity ?? false,
|
||||
firstTimeOnly: dto.firstTimeOnly ?? false,
|
||||
requiresApproval: dto.requiresApproval ?? false,
|
||||
roles: (dto.roles ?? []).filter((r) => ROLES.includes(r)),
|
||||
teamIds: (dto.teamIds ?? []).filter((t) => typeof t === 'string' && t).slice(0, 50),
|
||||
};
|
||||
}
|
||||
|
||||
async listRules(actor: Actor): Promise<GamificationRuleDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const [rows, counts] = await Promise.all([
|
||||
this.prisma.gamificationRule.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['ruleId'],
|
||||
where: { organizationId: actor.organizationId, kind: 'rule', status: 'approved' },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const countMap = new Map(counts.map((c) => [c.ruleId ?? '', c._count._all]));
|
||||
return rows.map((r) => this.toRuleDto(r, countMap.get(r.id) ?? 0));
|
||||
}
|
||||
|
||||
private async findRule(actor: Actor, id: string) {
|
||||
const row = await this.prisma.gamificationRule.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('قانون پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
async createRule(actor: Actor, dto: SaveRuleDto): Promise<GamificationRuleDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const max = await this.prisma.gamificationRule.aggregate({
|
||||
where: { organizationId: actor.organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.gamificationRule.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
...this.ruleData(dto),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
this.game.invalidate(actor.organizationId);
|
||||
return this.toRuleDto(row);
|
||||
}
|
||||
|
||||
async updateRule(actor: Actor, id: string, dto: SaveRuleDto): Promise<GamificationRuleDto> {
|
||||
await this.assertAdmin(actor);
|
||||
await this.findRule(actor, id);
|
||||
const row = await this.prisma.gamificationRule.update({ where: { id }, data: this.ruleData(dto) });
|
||||
this.game.invalidate(actor.organizationId);
|
||||
return this.toRuleDto(row);
|
||||
}
|
||||
|
||||
async toggleRule(actor: Actor, id: string, isActive: boolean): Promise<GamificationRuleDto> {
|
||||
await this.assertAdmin(actor);
|
||||
await this.findRule(actor, id);
|
||||
const row = await this.prisma.gamificationRule.update({ where: { id }, data: { isActive } });
|
||||
this.game.invalidate(actor.organizationId);
|
||||
return this.toRuleDto(row);
|
||||
}
|
||||
|
||||
async duplicateRule(actor: Actor, id: string): Promise<GamificationRuleDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const src = await this.findRule(actor, id);
|
||||
const { id: _id, createdAt: _c, updatedAt: _u, conditions, ...rest } = src;
|
||||
const row = await this.prisma.gamificationRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
conditions: conditions as Prisma.InputJsonValue,
|
||||
name: `${src.name} (کپی)`,
|
||||
isActive: false,
|
||||
position: src.position + 1,
|
||||
},
|
||||
});
|
||||
this.game.invalidate(actor.organizationId);
|
||||
return this.toRuleDto(row);
|
||||
}
|
||||
|
||||
async deleteRule(actor: Actor, id: string): Promise<void> {
|
||||
await this.assertAdmin(actor);
|
||||
await this.findRule(actor, id);
|
||||
await this.prisma.gamificationRule.delete({ where: { id } });
|
||||
this.game.invalidate(actor.organizationId);
|
||||
}
|
||||
|
||||
/* ───────── تنظیم دستی ───────── */
|
||||
|
||||
async adjust(actor: Actor, field: 'score' | 'coins', dto: AdjustDto): Promise<GamificationTxDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const organizationId = actor.organizationId;
|
||||
const amount = Math.trunc(dto.amount);
|
||||
if (!amount) throw new BadRequestException('مقدار نمیتواند صفر باشد');
|
||||
const reason = dto.reason.trim();
|
||||
if (!reason) throw new BadRequestException('ثبت دلیل الزامی است');
|
||||
const member = (await this.game.members(organizationId)).find((m) => m.userId === dto.userId);
|
||||
if (!member) throw new NotFoundException('کاربر پیدا نشد');
|
||||
|
||||
if (field === 'coins' && amount < 0) {
|
||||
const wallet = await this.prisma.gamificationWallet.findUnique({
|
||||
where: { organizationId_userId: { organizationId, userId: dto.userId } },
|
||||
});
|
||||
if ((wallet?.coins ?? 0) + amount < 0) {
|
||||
throw new BadRequestException('موجودی سکهٔ کاربر کافی نیست');
|
||||
}
|
||||
}
|
||||
|
||||
const label = field === 'score' ? 'امتیاز' : 'سکه';
|
||||
const tx = await this.game.record({
|
||||
organizationId,
|
||||
userId: dto.userId,
|
||||
kind: 'adjust',
|
||||
score: field === 'score' ? amount : 0,
|
||||
coins: field === 'coins' ? amount : 0,
|
||||
title: `${amount > 0 ? 'افزایش' : 'کاهش'} دستی ${label}`,
|
||||
reason,
|
||||
createdById: actor.userId,
|
||||
idempotencyKey: `adj:${randomUUID()}`,
|
||||
});
|
||||
if (!tx) throw new BadRequestException('ثبت انجام نشد');
|
||||
if (amount > 0) await this.game.afterAward(organizationId, dto.userId, null);
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: dto.userId,
|
||||
type: 'gamification_adjust',
|
||||
title: `${amount > 0 ? '+' : ''}${amount} ${label}`,
|
||||
body: reason,
|
||||
link: '/club?tab=history',
|
||||
entityType: 'gamification_tx',
|
||||
entityId: tx.id,
|
||||
});
|
||||
return (await this.game.toTxDtos(organizationId, [tx]))[0]!;
|
||||
}
|
||||
|
||||
/* ───────── تأیید امتیازهای معلق ───────── */
|
||||
|
||||
async pending(actor: Actor): Promise<GamificationTxDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const rows = await this.prisma.gamificationTransaction.findMany({
|
||||
where: { organizationId: actor.organizationId, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return this.game.toTxDtos(actor.organizationId, rows);
|
||||
}
|
||||
|
||||
async decideTx(actor: Actor, id: string, approve: boolean, dto: DecisionDto): Promise<GamificationTxDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const organizationId = actor.organizationId;
|
||||
const row = await this.prisma.$transaction(async (db) => {
|
||||
const changed = await db.gamificationTransaction.updateMany({
|
||||
where: { id, organizationId, status: 'pending' },
|
||||
data: {
|
||||
status: approve ? 'approved' : 'rejected',
|
||||
decidedById: actor.userId,
|
||||
decidedAt: new Date(),
|
||||
...(dto.note?.trim() ? { reason: dto.note.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (!changed.count) throw new BadRequestException('این مورد قبلاً بررسی شده است');
|
||||
const tx = await db.gamificationTransaction.findUniqueOrThrow({ where: { id } });
|
||||
if (approve) await this.game.applyWallet(db, organizationId, tx.userId, tx.score, tx.coins, tx.kind);
|
||||
return tx;
|
||||
});
|
||||
this.game.invalidateBoard(organizationId);
|
||||
if (approve) {
|
||||
await this.game.afterAward(organizationId, row.userId, isGamificationEvent(row.event) ? row.event : null);
|
||||
}
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: row.userId,
|
||||
type: 'gamification_decision',
|
||||
title: approve ? `امتیاز «${row.title}» تأیید شد` : `امتیاز «${row.title}» تأیید نشد`,
|
||||
body: dto.note?.trim() || null,
|
||||
link: '/club?tab=history',
|
||||
entityType: 'gamification_tx',
|
||||
entityId: row.id,
|
||||
});
|
||||
return (await this.game.toTxDtos(organizationId, [row]))[0]!;
|
||||
}
|
||||
|
||||
/* ───────── درخواستهای پاداش ───────── */
|
||||
|
||||
async redemptions(actor: Actor, status?: string): Promise<GamificationRedemptionDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const rows = await this.prisma.gamificationRedemption.findMany({
|
||||
where: { organizationId: actor.organizationId, ...(status ? { status } : {}) },
|
||||
include: { reward: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return this.game.toRedemptionDtos(actor.organizationId, rows);
|
||||
}
|
||||
|
||||
async decideRedemption(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
action: 'approve' | 'reject' | 'deliver',
|
||||
dto: DecisionDto,
|
||||
): Promise<GamificationRedemptionDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const organizationId = actor.organizationId;
|
||||
const allowedFrom: Record<typeof action, string[]> = {
|
||||
approve: ['pending'],
|
||||
reject: ['pending', 'approved'],
|
||||
deliver: ['pending', 'approved'],
|
||||
};
|
||||
const next = action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'delivered';
|
||||
const row = await this.prisma.$transaction(async (db) => {
|
||||
const changed = await db.gamificationRedemption.updateMany({
|
||||
where: { id, organizationId, status: { in: allowedFrom[action] } },
|
||||
data: {
|
||||
status: next,
|
||||
decidedById: actor.userId,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: dto.note?.trim() || null,
|
||||
},
|
||||
});
|
||||
if (!changed.count) throw new BadRequestException('وضعیت این درخواست قابل تغییر نیست');
|
||||
const red = await db.gamificationRedemption.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { reward: true },
|
||||
});
|
||||
if (action === 'reject') {
|
||||
await db.gamificationTransaction.create({
|
||||
data: {
|
||||
organizationId,
|
||||
userId: red.userId,
|
||||
kind: 'refund',
|
||||
coins: red.cost,
|
||||
status: 'approved',
|
||||
title: `بازگشت سکهٔ پاداش «${red.reward.name}»`,
|
||||
entityType: 'redemption',
|
||||
entityId: red.id,
|
||||
idempotencyKey: `refund:${red.id}`,
|
||||
reason: dto.note?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.game.applyWallet(db, organizationId, red.userId, 0, red.cost, 'refund');
|
||||
if (red.reward.stock != null) {
|
||||
await db.gamificationReward.update({
|
||||
where: { id: red.rewardId },
|
||||
data: { stock: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
return red;
|
||||
});
|
||||
const titles = {
|
||||
approve: `درخواست «${row.reward.name}» تأیید شد`,
|
||||
reject: `درخواست «${row.reward.name}» رد شد و سکهها برگشت`,
|
||||
deliver: `پاداش «${row.reward.name}» تحویل شد`,
|
||||
};
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: row.userId,
|
||||
type: 'gamification_redemption',
|
||||
title: titles[action],
|
||||
body: dto.note?.trim() || null,
|
||||
link: '/club?tab=store',
|
||||
entityType: 'gamification_redemption',
|
||||
entityId: row.id,
|
||||
});
|
||||
return (await this.game.toRedemptionDtos(organizationId, [row]))[0]!;
|
||||
}
|
||||
|
||||
/* ───────── پاداشها ───────── */
|
||||
|
||||
async listRewards(actor: Actor): Promise<GamificationRewardDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const rows = await this.prisma.gamificationReward.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: [{ position: 'asc' }, { cost: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => this.game.toRewardDto(r));
|
||||
}
|
||||
|
||||
private rewardData(dto: SaveRewardDto) {
|
||||
return {
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
cost: Math.max(1, Math.trunc(dto.cost)),
|
||||
icon: dto.icon?.trim() || 'gift',
|
||||
color: dto.color?.trim() || '#14b8a6',
|
||||
stock: dto.stock == null ? null : Math.max(0, Math.trunc(dto.stock)),
|
||||
isActive: dto.isActive ?? true,
|
||||
requiresApproval: dto.requiresApproval ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
async createReward(actor: Actor, dto: SaveRewardDto) {
|
||||
await this.assertAdmin(actor);
|
||||
const count = await this.prisma.gamificationReward.count({ where: { organizationId: actor.organizationId } });
|
||||
const row = await this.prisma.gamificationReward.create({
|
||||
data: { organizationId: actor.organizationId, ...this.rewardData(dto), position: count },
|
||||
});
|
||||
return this.game.toRewardDto(row);
|
||||
}
|
||||
|
||||
async updateReward(actor: Actor, id: string, dto: SaveRewardDto) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationReward', actor, id);
|
||||
const row = await this.prisma.gamificationReward.update({ where: { id }, data: this.rewardData(dto) });
|
||||
return this.game.toRewardDto(row);
|
||||
}
|
||||
|
||||
async deleteReward(actor: Actor, id: string) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationReward', actor, id);
|
||||
const used = await this.prisma.gamificationRedemption.count({ where: { rewardId: id } });
|
||||
if (used) {
|
||||
await this.prisma.gamificationReward.update({ where: { id }, data: { isActive: false } });
|
||||
return;
|
||||
}
|
||||
await this.prisma.gamificationReward.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/* ───────── نشانها ───────── */
|
||||
|
||||
private badgeData(dto: SaveBadgeDto) {
|
||||
if (!(dto.metric in GAMIFICATION_BADGE_METRICS)) throw new BadRequestException('شاخص نامعتبر است');
|
||||
if (dto.metric === 'event_count' && !isGamificationEvent(dto.event)) {
|
||||
throw new BadRequestException('رویداد را انتخاب کنید');
|
||||
}
|
||||
return {
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
icon: dto.icon?.trim() || 'medal',
|
||||
color: dto.color?.trim() || '#f59e0b',
|
||||
metric: dto.metric,
|
||||
event: dto.metric === 'event_count' ? dto.event! : null,
|
||||
threshold: Math.max(1, Math.trunc(dto.threshold)),
|
||||
coins: Math.max(0, Math.trunc(dto.coins ?? 0)),
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
async listBadges(actor: Actor): Promise<GamificationBadgeDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const [rows, counts] = await Promise.all([
|
||||
this.prisma.gamificationBadge.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
this.prisma.gamificationUserBadge.groupBy({
|
||||
by: ['badgeId'],
|
||||
where: { organizationId: actor.organizationId },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const countMap = new Map(counts.map((c) => [c.badgeId, c._count._all]));
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
icon: b.icon,
|
||||
color: b.color,
|
||||
metric: b.metric as GamificationBadgeMetric,
|
||||
event: isGamificationEvent(b.event) ? b.event : null,
|
||||
threshold: b.threshold,
|
||||
coins: b.coins,
|
||||
isActive: b.isActive,
|
||||
earnedCount: countMap.get(b.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async createBadge(actor: Actor, dto: SaveBadgeDto) {
|
||||
await this.assertAdmin(actor);
|
||||
const count = await this.prisma.gamificationBadge.count({ where: { organizationId: actor.organizationId } });
|
||||
await this.prisma.gamificationBadge.create({
|
||||
data: { organizationId: actor.organizationId, ...this.badgeData(dto), position: count },
|
||||
});
|
||||
return this.listBadges(actor);
|
||||
}
|
||||
|
||||
async updateBadge(actor: Actor, id: string, dto: SaveBadgeDto) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationBadge', actor, id);
|
||||
await this.prisma.gamificationBadge.update({ where: { id }, data: this.badgeData(dto) });
|
||||
return this.listBadges(actor);
|
||||
}
|
||||
|
||||
async deleteBadge(actor: Actor, id: string) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationBadge', actor, id);
|
||||
await this.prisma.gamificationBadge.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/* ───────── بونوسها ───────── */
|
||||
|
||||
private bonusData(dto: SaveBonusDto) {
|
||||
if (!(dto.kind in GAMIFICATION_BONUS_KINDS)) throw new BadRequestException('نوع بونوس نامعتبر است');
|
||||
if (dto.kind === 'event_count_period' && !isGamificationEvent(dto.event)) {
|
||||
throw new BadRequestException('رویداد را انتخاب کنید');
|
||||
}
|
||||
if (!dto.score && !dto.coins) throw new BadRequestException('امتیاز یا سکه باید بیشتر از صفر باشد');
|
||||
const period = dto.period === 'day' || dto.period === 'week' ? dto.period : 'month';
|
||||
return {
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
kind: dto.kind as GamificationBonusKind,
|
||||
event: dto.kind === 'event_count_period' ? dto.event! : null,
|
||||
threshold: Math.max(1, Math.trunc(dto.threshold)),
|
||||
period: dto.kind === 'top_of_month' ? 'month' : period,
|
||||
score: Math.max(0, Math.trunc(dto.score ?? 0)),
|
||||
coins: Math.max(0, Math.trunc(dto.coins ?? 0)),
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
async listBonuses(actor: Actor): Promise<GamificationBonusDto[]> {
|
||||
await this.assertAdmin(actor);
|
||||
const [rows, counts] = await Promise.all([
|
||||
this.prisma.gamificationBonus.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['bonusId'],
|
||||
where: { organizationId: actor.organizationId, kind: 'bonus', status: 'approved' },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const countMap = new Map(counts.map((c) => [c.bonusId ?? '', c._count._all]));
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
kind: b.kind as GamificationBonusKind,
|
||||
event: isGamificationEvent(b.event) ? b.event : null,
|
||||
threshold: b.threshold,
|
||||
period: (b.period === 'day' || b.period === 'week' ? b.period : 'month') as GamificationBonusDto['period'],
|
||||
score: b.score,
|
||||
coins: b.coins,
|
||||
isActive: b.isActive,
|
||||
awardedCount: countMap.get(b.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async createBonus(actor: Actor, dto: SaveBonusDto) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.prisma.gamificationBonus.create({
|
||||
data: { organizationId: actor.organizationId, ...this.bonusData(dto) },
|
||||
});
|
||||
return this.listBonuses(actor);
|
||||
}
|
||||
|
||||
async updateBonus(actor: Actor, id: string, dto: SaveBonusDto) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationBonus', actor, id);
|
||||
await this.prisma.gamificationBonus.update({ where: { id }, data: this.bonusData(dto) });
|
||||
return this.listBonuses(actor);
|
||||
}
|
||||
|
||||
async deleteBonus(actor: Actor, id: string) {
|
||||
await this.assertAdmin(actor);
|
||||
await this.ensureOwned('gamificationBonus', actor, id);
|
||||
await this.prisma.gamificationBonus.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureOwned(
|
||||
model: 'gamificationReward' | 'gamificationBadge' | 'gamificationBonus',
|
||||
actor: Actor,
|
||||
id: string,
|
||||
) {
|
||||
const where = { id, organizationId: actor.organizationId };
|
||||
const found =
|
||||
model === 'gamificationReward'
|
||||
? await this.prisma.gamificationReward.findFirst({ where, select: { id: true } })
|
||||
: model === 'gamificationBadge'
|
||||
? await this.prisma.gamificationBadge.findFirst({ where, select: { id: true } })
|
||||
: await this.prisma.gamificationBonus.findFirst({ where, select: { id: true } });
|
||||
if (!found) throw new NotFoundException('مورد پیدا نشد');
|
||||
}
|
||||
|
||||
/* ───────── تنظیمات ───────── */
|
||||
|
||||
async settings(actor: Actor): Promise<GamificationSettingsDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const row = await this.game.ensureDefaults(actor.organizationId);
|
||||
return this.game.toSettingsDto(row);
|
||||
}
|
||||
|
||||
async updateSettings(actor: Actor, dto: UpdateSettingsDto): Promise<GamificationSettingsDto> {
|
||||
await this.assertAdmin(actor);
|
||||
await this.game.ensureDefaults(actor.organizationId);
|
||||
const row = await this.prisma.gamificationSettings.update({
|
||||
where: { organizationId: actor.organizationId },
|
||||
data: {
|
||||
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
|
||||
...(dto.levels !== undefined
|
||||
? { levels: normalizeLevels(dto.levels) as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(dto.monthlyGoal !== undefined ? { monthlyGoal: Math.max(1, Math.trunc(dto.monthlyGoal)) } : {}),
|
||||
...(dto.showFullLeaderboard !== undefined ? { showFullLeaderboard: dto.showFullLeaderboard } : {}),
|
||||
},
|
||||
});
|
||||
this.game.invalidate(actor.organizationId);
|
||||
return this.game.toSettingsDto(row);
|
||||
}
|
||||
|
||||
/* ───────── گزارش مدیریتی ───────── */
|
||||
|
||||
async report(actor: Actor, periodRaw: unknown): Promise<GamificationReportDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const organizationId = actor.organizationId;
|
||||
const period = parsePeriod(periodRaw);
|
||||
const from = periodStart(period);
|
||||
const createdAt = from ? { gte: from } : undefined;
|
||||
const base: Prisma.GamificationTransactionWhereInput = {
|
||||
organizationId,
|
||||
status: 'approved',
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
};
|
||||
const members = await this.game.members(organizationId);
|
||||
const names = new Map(members.map((m) => [m.userId, m.fullName]));
|
||||
|
||||
const [earned, spent, byUser, byEvent, pendingApprovals, pendingRedemptions, perUserEvent, settings] =
|
||||
await Promise.all([
|
||||
this.prisma.gamificationTransaction.aggregate({
|
||||
where: { ...base, kind: { notIn: ['redeem', 'refund'] } },
|
||||
_sum: { score: true, coins: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.gamificationTransaction.aggregate({
|
||||
where: { ...base, kind: { in: ['redeem', 'refund'] } },
|
||||
_sum: { coins: true },
|
||||
}),
|
||||
this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['userId'],
|
||||
where: { ...base, kind: 'rule' },
|
||||
_count: { _all: true },
|
||||
_sum: { score: true },
|
||||
}),
|
||||
this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['event'],
|
||||
where: { ...base, kind: 'rule' },
|
||||
_count: { _all: true },
|
||||
_sum: { score: true, coins: true },
|
||||
}),
|
||||
this.prisma.gamificationTransaction.count({ where: { organizationId, status: 'pending' } }),
|
||||
this.prisma.gamificationRedemption.count({ where: { organizationId, status: 'pending' } }),
|
||||
this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['userId', 'event'],
|
||||
where: {
|
||||
...base,
|
||||
kind: 'rule',
|
||||
event: { in: ['DEAL_WON', 'LEAD_CREATED', 'TASK_COMPLETED', 'ROUTINE_COMPLETED'] },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.game.ensureDefaults(organizationId),
|
||||
]);
|
||||
|
||||
const top = (entries: Array<[string, number]>, n = 5): GamificationReportUserRow[] =>
|
||||
entries
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n)
|
||||
.map(([userId, value]) => ({ userId, fullName: names.get(userId) ?? '—', value }));
|
||||
|
||||
const sumEvents = (events: string[]) => {
|
||||
const map = new Map<string, number>();
|
||||
for (const r of perUserEvent) {
|
||||
if (r.event && events.includes(r.event)) {
|
||||
map.set(r.userId, (map.get(r.userId) ?? 0) + r._count._all);
|
||||
}
|
||||
}
|
||||
return [...map.entries()];
|
||||
};
|
||||
|
||||
let topGrowth: GamificationReportUserRow[] = [];
|
||||
const prev = previousPeriod(period);
|
||||
if (prev) {
|
||||
const prevRows = await this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['userId'],
|
||||
where: { organizationId, status: 'approved', createdAt: { gte: prev.from, lt: prev.to } },
|
||||
_sum: { score: true },
|
||||
});
|
||||
const prevMap = new Map(prevRows.map((r) => [r.userId, r._sum.score ?? 0]));
|
||||
topGrowth = top(byUser.map((r) => [r.userId, (r._sum.score ?? 0) - (prevMap.get(r.userId) ?? 0)]));
|
||||
}
|
||||
|
||||
const activeIds = new Set(byUser.map((r) => r.userId));
|
||||
const inactive = members
|
||||
.filter((m) => !activeIds.has(m.userId))
|
||||
.slice(0, 20)
|
||||
.map((m) => ({ userId: m.userId, fullName: m.fullName, value: 0 }));
|
||||
|
||||
return {
|
||||
period,
|
||||
totals: {
|
||||
score: earned._sum.score ?? 0,
|
||||
coinsEarned: earned._sum.coins ?? 0,
|
||||
coinsSpent: Math.abs(spent._sum.coins ?? 0),
|
||||
activeUsers: activeIds.size,
|
||||
totalUsers: members.length,
|
||||
transactions: earned._count._all,
|
||||
pendingApprovals,
|
||||
pendingRedemptions,
|
||||
},
|
||||
byEvent: byEvent
|
||||
.filter((e) => isGamificationEvent(e.event))
|
||||
.map((e) => ({
|
||||
event: e.event as GamificationReportDto['byEvent'][number]['event'],
|
||||
label: GAMIFICATION_EVENTS[e.event as keyof typeof GAMIFICATION_EVENTS].label,
|
||||
count: e._count._all,
|
||||
score: e._sum.score ?? 0,
|
||||
coins: e._sum.coins ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
topActive: top(byUser.map((r) => [r.userId, r._count._all])),
|
||||
topGrowth,
|
||||
topDeals: top(sumEvents(['DEAL_WON'])),
|
||||
topLeads: top(sumEvents(['LEAD_CREATED'])),
|
||||
topTasks: top(sumEvents(['TASK_COMPLETED', 'ROUTINE_COMPLETED'])),
|
||||
inactive,
|
||||
roi: await this.roi(organizationId, settings.enabledAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** مقایسهٔ میانگین روزانهٔ شاخصها در ۳۰ روز قبل و بعد از شروع باشگاه */
|
||||
private async roi(organizationId: string, enabledAt: Date): Promise<GamificationReportDto['roi']> {
|
||||
const since = enabledAt;
|
||||
const beforeFrom = new Date(since.getTime() - ROI_WINDOW_DAYS * 86_400_000);
|
||||
const afterTo = new Date(Math.min(Date.now(), since.getTime() + ROI_WINDOW_DAYS * 86_400_000));
|
||||
const afterDays = Math.max(1, (afterTo.getTime() - since.getTime()) / 86_400_000);
|
||||
const ranges = [
|
||||
{ gte: beforeFrom, lt: since },
|
||||
{ gte: since, lt: afterTo },
|
||||
] as const;
|
||||
|
||||
const count = async (fn: (r: { gte: Date; lt: Date }) => Promise<number>) =>
|
||||
Promise.all(ranges.map((r) => fn(r)));
|
||||
|
||||
const [activities, calls, leadCalls, leads, converted, customers, wonCount] = await Promise.all([
|
||||
count((r) => this.prisma.activity.count({ where: { organizationId, createdAt: r } })),
|
||||
count((r) => this.prisma.activity.count({ where: { organizationId, type: 'call', createdAt: r } })),
|
||||
count((r) => this.prisma.leadEvent.count({ where: { organizationId, type: 'call', createdAt: r } })),
|
||||
count((r) => this.prisma.lead.count({ where: { organizationId, createdAt: r } })),
|
||||
count((r) => this.prisma.lead.count({ where: { organizationId, convertedAt: r } })),
|
||||
count((r) => this.prisma.person.count({ where: { organizationId, createdAt: r } })),
|
||||
count((r) => this.prisma.deal.count({ where: { organizationId, status: 'won', closedAt: r } })),
|
||||
]);
|
||||
const wonAmount = await Promise.all(
|
||||
ranges.map(async (r) => {
|
||||
const agg = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, status: 'won', closedAt: r },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
return Number(agg._sum.amount ?? 0);
|
||||
}),
|
||||
);
|
||||
const routineRate = await Promise.all(
|
||||
ranges.map(async (r) => {
|
||||
const fromKey = tehranDayKey(r.gte);
|
||||
const toKey = addDays(tehranDayKey(r.lt), -1);
|
||||
const where: Prisma.RoutineTaskWhereInput = {
|
||||
organizationId,
|
||||
status: { not: 'cancelled' },
|
||||
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
|
||||
};
|
||||
const [total, done] = await Promise.all([
|
||||
this.prisma.routineTask.count({ where }),
|
||||
this.prisma.routineTask.count({ where: { ...where, status: 'completed' } }),
|
||||
]);
|
||||
return total ? Math.round((done / total) * 100) : 0;
|
||||
}),
|
||||
);
|
||||
|
||||
const perDay = (pair: number[]): [number, number] => [
|
||||
Math.round((pair[0]! / ROI_WINDOW_DAYS) * 10) / 10,
|
||||
Math.round((pair[1]! / afterDays) * 10) / 10,
|
||||
];
|
||||
const metric = (key: string, label: string, [before, after]: [number, number]): GamificationRoiMetric => ({
|
||||
key,
|
||||
label,
|
||||
before,
|
||||
after,
|
||||
changePct: before > 0 ? Math.round(((after - before) / before) * 100) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
since: since.toISOString(),
|
||||
windowDays: ROI_WINDOW_DAYS,
|
||||
metrics: [
|
||||
metric('activities', 'فعالیت ثبتشده در روز', perDay(activities)),
|
||||
metric('calls', 'تماس ثبتشده در روز', perDay([calls[0]! + leadCalls[0]!, calls[1]! + leadCalls[1]!])),
|
||||
metric('leads', 'لید جدید در روز', perDay(leads)),
|
||||
metric('converted', 'تبدیل لید به مشتری در روز', perDay(converted)),
|
||||
metric('customers', 'مشتری جدید در روز', perDay(customers)),
|
||||
metric('won', 'معاملهٔ برنده در روز', perDay(wonCount)),
|
||||
metric('wonAmount', 'مبلغ فروش در روز', perDay(wonAmount)),
|
||||
metric('routine', 'درصد انجام کارهای روتین', [routineRate[0]!, routineRate[1]!]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { jalaliMonthKeyOf, previousPeriod } from './gamification-engine';
|
||||
import { GamificationService } from './gamification.service';
|
||||
|
||||
@Injectable()
|
||||
export class GamificationCronService {
|
||||
private readonly logger = new Logger(GamificationCronService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
/** پاداش «نفر اول ماه» برای ماه شمسی گذشته؛ روزانه اجرا میشود و تکراری ثبت نمیکند */
|
||||
@Cron('20 0 * * *', { timeZone: 'Asia/Tehran' })
|
||||
async awardTopOfMonth() {
|
||||
const range = previousPeriod('month');
|
||||
if (!range) return;
|
||||
const monthKey = jalaliMonthKeyOf(range.from);
|
||||
const bonuses = await this.prisma.gamificationBonus.findMany({
|
||||
where: { kind: 'top_of_month', isActive: true },
|
||||
});
|
||||
for (const bonus of bonuses) {
|
||||
try {
|
||||
const settings = await this.game.settingsFor(bonus.organizationId);
|
||||
if (!settings?.enabled) continue;
|
||||
const memberIds = new Set((await this.game.members(bonus.organizationId)).map((m) => m.userId));
|
||||
const rows = await this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['userId'],
|
||||
where: {
|
||||
organizationId: bonus.organizationId,
|
||||
status: 'approved',
|
||||
createdAt: { gte: range.from, lt: range.to },
|
||||
},
|
||||
_sum: { score: true },
|
||||
});
|
||||
const winner = rows
|
||||
.filter((r) => memberIds.has(r.userId) && (r._sum.score ?? 0) > 0)
|
||||
.sort((a, b) => (b._sum.score ?? 0) - (a._sum.score ?? 0))[0];
|
||||
if (!winner) continue;
|
||||
await this.game.record({
|
||||
organizationId: bonus.organizationId,
|
||||
userId: winner.userId,
|
||||
kind: 'bonus',
|
||||
bonusId: bonus.id,
|
||||
score: bonus.score,
|
||||
coins: bonus.coins,
|
||||
title: `پاداش ویژه: ${bonus.name}`,
|
||||
idempotencyKey: `b:${bonus.id}:m:${monthKey}`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`top-of-month ${bonus.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
GamificationBadgeMetric,
|
||||
GamificationBonusKind,
|
||||
GamificationCondition,
|
||||
GamificationEvent,
|
||||
} from '@mento/shared';
|
||||
|
||||
type RuleSeed = {
|
||||
name: string;
|
||||
event: GamificationEvent;
|
||||
score: number;
|
||||
coins: number;
|
||||
conditions?: GamificationCondition[];
|
||||
maxPerDay?: number;
|
||||
cooldownMinutes?: number;
|
||||
oncePerEntity?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES: RuleSeed[] = [
|
||||
{ name: 'ثبت لید جدید', event: 'LEAD_CREATED', score: 5, coins: 1, maxPerDay: 40 },
|
||||
{ name: 'پیشرفت لید به مرحله بعد', event: 'LEAD_STAGE_CHANGED', score: 10, coins: 2, maxPerDay: 60 },
|
||||
{ name: 'تبدیل لید به مشتری', event: 'LEAD_CONVERTED', score: 30, coins: 10, oncePerEntity: true },
|
||||
{ name: 'ثبت مشتری جدید', event: 'CUSTOMER_CREATED', score: 20, coins: 5, maxPerDay: 40 },
|
||||
{ name: 'ایجاد معامله', event: 'DEAL_CREATED', score: 10, coins: 2, maxPerDay: 30 },
|
||||
{ name: 'برنده شدن معامله', event: 'DEAL_WON', score: 50, coins: 20, oncePerEntity: true },
|
||||
{ name: 'ثبت تماس', event: 'CALL_LOGGED', score: 2, coins: 0, maxPerDay: 80, cooldownMinutes: 1 },
|
||||
{ name: 'ثبت جلسه', event: 'MEETING_CREATED', score: 10, coins: 3, maxPerDay: 10 },
|
||||
{ name: 'ثبت پیگیری', event: 'FOLLOW_UP_CREATED', score: 3, coins: 1, maxPerDay: 40 },
|
||||
{ name: 'ثبت یادداشت', event: 'NOTE_CREATED', score: 1, coins: 0, maxPerDay: 30 },
|
||||
{ name: 'انجام کار', event: 'TASK_COMPLETED', score: 5, coins: 1, maxPerDay: 40 },
|
||||
{
|
||||
name: 'انجام کار روتین',
|
||||
event: 'ROUTINE_COMPLETED',
|
||||
score: 2,
|
||||
coins: 0,
|
||||
conditions: [{ field: 'onTime', op: 'is_false' }],
|
||||
},
|
||||
{
|
||||
name: 'انجام بهموقع کار روتین',
|
||||
event: 'ROUTINE_COMPLETED',
|
||||
score: 5,
|
||||
coins: 1,
|
||||
conditions: [{ field: 'onTime', op: 'is_true' }],
|
||||
},
|
||||
{ name: 'ثبت پیشفاکتور', event: 'QUOTE_CREATED', score: 10, coins: 3, maxPerDay: 20 },
|
||||
{ name: 'صدور فاکتور', event: 'INVOICE_ISSUED', score: 15, coins: 5, oncePerEntity: true },
|
||||
{ name: 'ثبت پرداخت', event: 'PAYMENT_REGISTERED', score: 20, coins: 5 },
|
||||
{ name: 'حل تیکت', event: 'TICKET_RESOLVED', score: 10, coins: 3, oncePerEntity: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_REWARDS = [
|
||||
{ name: 'کارت هدیه', description: 'کارت هدیه خرید', cost: 500, icon: 'gift', color: '#14b8a6' },
|
||||
{ name: 'ناهار تیمی', description: 'ناهار مهمان شرکت', cost: 800, icon: 'star', color: '#f59e0b' },
|
||||
{ name: 'مرخصی تشویقی', description: 'یک روز مرخصی تشویقی', cost: 2000, icon: 'calendar-sun', color: '#8b5cf6' },
|
||||
{ name: 'هدفون', description: 'هدفون بیسیم', cost: 3000, icon: 'trophy', color: '#ef4444' },
|
||||
];
|
||||
|
||||
export const DEFAULT_BADGES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
metric: GamificationBadgeMetric;
|
||||
event?: GamificationEvent;
|
||||
threshold: number;
|
||||
coins: number;
|
||||
}> = [
|
||||
{ name: 'اولین فروش', description: 'اولین معامله برنده', icon: 'trophy', color: '#f59e0b', metric: 'event_count', event: 'DEAL_WON', threshold: 1, coins: 20 },
|
||||
{ name: '۱۰ فروش موفق', description: '۱۰ معامله برنده', icon: 'crown', color: '#eab308', metric: 'event_count', event: 'DEAL_WON', threshold: 10, coins: 100 },
|
||||
{ name: '۱۰۰ تماس', description: 'ثبت ۱۰۰ تماس', icon: 'phone', color: '#0ea5e9', metric: 'event_count', event: 'CALL_LOGGED', threshold: 100, coins: 50 },
|
||||
{ name: '۵۰ مشتری جدید', description: 'ثبت ۵۰ مشتری', icon: 'users', color: '#22c55e', metric: 'event_count', event: 'CUSTOMER_CREATED', threshold: 50, coins: 80 },
|
||||
{ name: '۱۰۰ لید', description: 'ثبت ۱۰۰ لید', icon: 'funnel', color: '#14b8a6', metric: 'event_count', event: 'LEAD_CREATED', threshold: 100, coins: 60 },
|
||||
{ name: '۳۰ روز روتین کامل', description: '۳۰ روز پیاپی انجام کامل کارهای روتین', icon: 'fire', color: '#f97316', metric: 'routine_streak', threshold: 30, coins: 150 },
|
||||
{ name: 'باشگاه هزار امتیازی', description: 'رسیدن به ۱۰۰۰ امتیاز', icon: 'medal', color: '#8b5cf6', metric: 'score_total', threshold: 1000, coins: 50 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BONUSES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
kind: GamificationBonusKind;
|
||||
event?: GamificationEvent;
|
||||
threshold: number;
|
||||
period: 'day' | 'week' | 'month';
|
||||
score: number;
|
||||
coins: number;
|
||||
}> = [
|
||||
{ name: '۱۰ فروش در ماه', description: '۱۰ معامله برنده در یک ماه', kind: 'event_count_period', event: 'DEAL_WON', threshold: 10, period: 'month', score: 100, coins: 200 },
|
||||
{ name: '۵ روز پیاپی روتین کامل', description: 'انجام کامل روتینها در ۵ روز پیاپی', kind: 'routine_streak', threshold: 5, period: 'day', score: 30, coins: 100 },
|
||||
{ name: 'نفر اول ماه', description: 'بیشترین امتیاز در ماه', kind: 'top_of_month', threshold: 1, period: 'month', score: 0, coins: 500 },
|
||||
];
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
GamificationCondition,
|
||||
GamificationConditionOp,
|
||||
GamificationLevelDef,
|
||||
GamificationLevelDto,
|
||||
GamificationPeriod,
|
||||
} from '@mento/shared';
|
||||
import { GAMIFICATION_CONDITION_OPS, GAMIFICATION_FIELDS } from '@mento/shared';
|
||||
import { toGregorian, toJalali } from '../common/jalali';
|
||||
import {
|
||||
addDays,
|
||||
persianWeekday,
|
||||
tehranDayKey,
|
||||
tehranDayStart,
|
||||
tehranHour,
|
||||
weekStartKey,
|
||||
} from '../routines/routine-dates';
|
||||
|
||||
export type Facts = Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_LEVELS: GamificationLevelDef[] = [
|
||||
{ name: 'تازهکار', minScore: 0 },
|
||||
{ name: 'برنزی', minScore: 250 },
|
||||
{ name: 'نقرهای', minScore: 600 },
|
||||
{ name: 'حرفهای', minScore: 1200 },
|
||||
{ name: 'طلایی', minScore: 2000 },
|
||||
{ name: 'استاد', minScore: 3500 },
|
||||
];
|
||||
|
||||
/* ───────── شرطها ───────── */
|
||||
|
||||
function toNumber(v: unknown): number | null {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v.replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
return String(v).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toBool(v: unknown): boolean {
|
||||
if (typeof v === 'string') return v === 'true' || v === '1';
|
||||
return Boolean(v);
|
||||
}
|
||||
|
||||
export function evaluateCondition(cond: GamificationCondition, facts: Facts): boolean {
|
||||
const actual = facts[cond.field];
|
||||
switch (cond.op) {
|
||||
case 'is_true':
|
||||
return toBool(actual);
|
||||
case 'is_false':
|
||||
return !toBool(actual);
|
||||
case 'gt':
|
||||
case 'gte':
|
||||
case 'lt':
|
||||
case 'lte': {
|
||||
const a = toNumber(actual);
|
||||
const b = toNumber(cond.value);
|
||||
if (a == null || b == null) return false;
|
||||
if (cond.op === 'gt') return a > b;
|
||||
if (cond.op === 'gte') return a >= b;
|
||||
if (cond.op === 'lt') return a < b;
|
||||
return a <= b;
|
||||
}
|
||||
case 'eq':
|
||||
case 'neq': {
|
||||
const a = toNumber(actual);
|
||||
const b = toNumber(cond.value);
|
||||
const same = a != null && b != null ? a === b : toText(actual) === toText(cond.value);
|
||||
return cond.op === 'eq' ? same : !same;
|
||||
}
|
||||
case 'contains':
|
||||
return toText(actual).includes(toText(cond.value));
|
||||
case 'in':
|
||||
return String(cond.value ?? '')
|
||||
.split(/[,،]/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.includes(toText(actual));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** همهٔ شرطها باید برقرار باشند (AND) */
|
||||
export function matchesConditions(conditions: GamificationCondition[], facts: Facts): boolean {
|
||||
return conditions.every((c) => evaluateCondition(c, facts));
|
||||
}
|
||||
|
||||
export function sanitizeConditions(raw: unknown): GamificationCondition[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: GamificationCondition[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const { field, op, value } = item as Record<string, unknown>;
|
||||
if (typeof field !== 'string' || !(field in GAMIFICATION_FIELDS)) continue;
|
||||
if (typeof op !== 'string' || !(op in GAMIFICATION_CONDITION_OPS)) continue;
|
||||
out.push({
|
||||
field,
|
||||
op: op as GamificationConditionOp,
|
||||
value: typeof value === 'number' || typeof value === 'string' ? value : null,
|
||||
});
|
||||
}
|
||||
return out.slice(0, 10);
|
||||
}
|
||||
|
||||
/** فیلدهای عمومی که برای همهٔ رویدادها محاسبه میشوند */
|
||||
export function commonFacts(now: Date = new Date()): Facts {
|
||||
return { hour: tehranHour(now), weekday: persianWeekday(tehranDayKey(now)) };
|
||||
}
|
||||
|
||||
/* ───────── محدودیتها ───────── */
|
||||
|
||||
export type RuleLimits = {
|
||||
maxPerDay: number | null;
|
||||
maxCoinsPerDay: number | null;
|
||||
maxPerUser: number | null;
|
||||
cooldownMinutes: number | null;
|
||||
firstTimeOnly: boolean;
|
||||
coins: number;
|
||||
};
|
||||
|
||||
export type RuleUsage = {
|
||||
todayCount: number;
|
||||
todayCoins: number;
|
||||
totalCount: number;
|
||||
lastAt: Date | null;
|
||||
};
|
||||
|
||||
/** دلیل رد امتیاز یا null اگر مجاز است */
|
||||
export function limitBlockReason(
|
||||
rule: RuleLimits,
|
||||
usage: RuleUsage,
|
||||
now: Date = new Date(),
|
||||
): string | null {
|
||||
if (rule.firstTimeOnly && usage.totalCount > 0) return 'first_time_only';
|
||||
if (rule.maxPerUser != null && usage.totalCount >= rule.maxPerUser) return 'max_per_user';
|
||||
if (rule.maxPerDay != null && usage.todayCount >= rule.maxPerDay) return 'max_per_day';
|
||||
if (
|
||||
rule.maxCoinsPerDay != null &&
|
||||
rule.coins > 0 &&
|
||||
usage.todayCoins + rule.coins > rule.maxCoinsPerDay
|
||||
) {
|
||||
return 'max_coins_per_day';
|
||||
}
|
||||
if (
|
||||
rule.cooldownMinutes != null &&
|
||||
rule.cooldownMinutes > 0 &&
|
||||
usage.lastAt &&
|
||||
now.getTime() - usage.lastAt.getTime() < rule.cooldownMinutes * 60_000
|
||||
) {
|
||||
return 'cooldown';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function needsUsage(rule: RuleLimits): boolean {
|
||||
return (
|
||||
rule.firstTimeOnly ||
|
||||
rule.maxPerUser != null ||
|
||||
rule.maxPerDay != null ||
|
||||
rule.maxCoinsPerDay != null ||
|
||||
(rule.cooldownMinutes != null && rule.cooldownMinutes > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── بازههای زمانی (تقویم شمسی، وقت تهران) ───────── */
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function jalaliOfKey(key: string) {
|
||||
const [gy, gm, gd] = key.split('-').map(Number) as [number, number, number];
|
||||
return toJalali(gy, gm, gd);
|
||||
}
|
||||
|
||||
function keyOfJalali(jy: number, jm: number, jd: number): string {
|
||||
const g = toGregorian(jy, jm, jd);
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
|
||||
}
|
||||
|
||||
function shiftJalaliMonth(jy: number, jm: number, delta: number) {
|
||||
const idx = jy * 12 + (jm - 1) + delta;
|
||||
return { jy: Math.floor(idx / 12), jm: (idx % 12) + 1 };
|
||||
}
|
||||
|
||||
function periodStartKey(period: Exclude<GamificationPeriod, 'all'>, key: string): string {
|
||||
if (period === 'today') return key;
|
||||
if (period === 'week') return weekStartKey(key);
|
||||
const { jy, jm } = jalaliOfKey(key);
|
||||
if (period === 'month') return keyOfJalali(jy, jm, 1);
|
||||
if (period === 'quarter') return keyOfJalali(jy, Math.floor((jm - 1) / 3) * 3 + 1, 1);
|
||||
return keyOfJalali(jy, 1, 1);
|
||||
}
|
||||
|
||||
export function periodStart(period: GamificationPeriod, now: Date = new Date()): Date | null {
|
||||
if (period === 'all') return null;
|
||||
return tehranDayStart(periodStartKey(period, tehranDayKey(now)));
|
||||
}
|
||||
|
||||
/** بازهٔ هماندازهٔ قبلی، برای محاسبهٔ رشد */
|
||||
export function previousPeriod(
|
||||
period: GamificationPeriod,
|
||||
now: Date = new Date(),
|
||||
): { from: Date; to: Date } | null {
|
||||
if (period === 'all') return null;
|
||||
const startKey = periodStartKey(period, tehranDayKey(now));
|
||||
let prevKey: string;
|
||||
if (period === 'today') prevKey = addDays(startKey, -1);
|
||||
else if (period === 'week') prevKey = addDays(startKey, -7);
|
||||
else {
|
||||
const months = period === 'month' ? 1 : period === 'quarter' ? 3 : 12;
|
||||
const { jy, jm } = jalaliOfKey(startKey);
|
||||
const p = shiftJalaliMonth(jy, jm, -months);
|
||||
prevKey = keyOfJalali(p.jy, p.jm, 1);
|
||||
}
|
||||
return { from: tehranDayStart(prevKey), to: tehranDayStart(startKey) };
|
||||
}
|
||||
|
||||
/** کلید ماه شمسی، مثل 1405-07 */
|
||||
export function jalaliMonthKeyOf(now: Date = new Date()): string {
|
||||
const { jy, jm } = jalaliOfKey(tehranDayKey(now));
|
||||
return `${jy}-${pad(jm)}`;
|
||||
}
|
||||
|
||||
/* ───────── سطح و رتبه ───────── */
|
||||
|
||||
export function normalizeLevels(raw: unknown): GamificationLevelDef[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_LEVELS;
|
||||
const levels = raw
|
||||
.filter((l): l is { name: unknown; minScore: unknown } => !!l && typeof l === 'object')
|
||||
.map((l) => ({ name: String(l.name ?? '').trim(), minScore: Math.max(0, Math.floor(Number(l.minScore) || 0)) }))
|
||||
.filter((l) => l.name)
|
||||
.sort((a, b) => a.minScore - b.minScore);
|
||||
if (!levels.length) return DEFAULT_LEVELS;
|
||||
levels[0] = { ...levels[0]!, minScore: 0 };
|
||||
return levels.slice(0, 20);
|
||||
}
|
||||
|
||||
export function levelFor(score: number, levels: GamificationLevelDef[]): GamificationLevelDto {
|
||||
const list = levels.length ? levels : DEFAULT_LEVELS;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
if (score >= list[i]!.minScore) idx = i;
|
||||
}
|
||||
const cur = list[idx]!;
|
||||
const next = list[idx + 1] ?? null;
|
||||
const span = next ? next.minScore - cur.minScore : 0;
|
||||
const progress = next
|
||||
? Math.max(0, Math.min(100, Math.round(((score - cur.minScore) / Math.max(1, span)) * 100)))
|
||||
: 100;
|
||||
return {
|
||||
level: idx + 1,
|
||||
name: cur.name,
|
||||
minScore: cur.minScore,
|
||||
nextName: next?.name ?? null,
|
||||
nextMinScore: next?.minScore ?? null,
|
||||
progress,
|
||||
toNext: next ? Math.max(0, next.minScore - score) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ScoreRow = { userId: string; score: number };
|
||||
|
||||
/** رتبهبندی رقابتی (۱، ۲، ۲، ۴)؛ همامتیازها به ترتیب نام ثابت میمانند */
|
||||
export function rankRows<T extends ScoreRow>(
|
||||
rows: T[],
|
||||
tieBreak: (a: T, b: T) => number = () => 0,
|
||||
): Array<T & { rank: number }> {
|
||||
const sorted = [...rows].sort((a, b) => b.score - a.score || tieBreak(a, b));
|
||||
let lastScore: number | null = null;
|
||||
let lastRank = 0;
|
||||
return sorted.map((row, i) => {
|
||||
if (row.score !== lastScore) {
|
||||
lastRank = i + 1;
|
||||
lastScore = row.score;
|
||||
}
|
||||
return { ...row, rank: lastRank };
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────── روزهای پیاپی ───────── */
|
||||
|
||||
/**
|
||||
* روزهای پیاپی که همهٔ روتینها انجام شدهاند.
|
||||
* روزهای بدون روتین زنجیره را نمیشکنند؛ امروزِ ناتمام هم نادیده گرفته میشود.
|
||||
*/
|
||||
export function routineStreak(
|
||||
days: Map<string, { total: number; completed: number }>,
|
||||
today: string,
|
||||
maxDays = 120,
|
||||
): number {
|
||||
let streak = 0;
|
||||
for (let i = 0; i < maxDays; i += 1) {
|
||||
const key = addDays(today, -i);
|
||||
const d = days.get(key);
|
||||
if (!d || d.total === 0) continue;
|
||||
if (d.completed >= d.total) {
|
||||
streak += 1;
|
||||
continue;
|
||||
}
|
||||
if (i === 0) continue;
|
||||
break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { GamificationService } from './gamification.service';
|
||||
import { RedeemDto, TxQueryDto } from './dto';
|
||||
|
||||
@Controller('gamification')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('gamification')
|
||||
export class GamificationController {
|
||||
constructor(private readonly game: GamificationService) {}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.game.me(user, period);
|
||||
}
|
||||
|
||||
@Get('pulse')
|
||||
pulse(@CurrentUser() user: Actor, @Query('since') since?: string) {
|
||||
return this.game.pulse(user, since);
|
||||
}
|
||||
|
||||
@Get('leaderboard')
|
||||
leaderboard(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('period') period?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.game.leaderboard(user, period, limit);
|
||||
}
|
||||
|
||||
@Get('leaderboard/teams')
|
||||
teams(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.game.teams(user, period);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
transactions(@CurrentUser() user: Actor, @Query() q: TxQueryDto) {
|
||||
return this.game.transactions(user, q);
|
||||
}
|
||||
|
||||
@Get('rules')
|
||||
rules(@CurrentUser() user: Actor) {
|
||||
return this.game.publicRules(user);
|
||||
}
|
||||
|
||||
@Get('feed')
|
||||
feed(@CurrentUser() user: Actor) {
|
||||
return this.game.feed(user);
|
||||
}
|
||||
|
||||
@Get('achievements')
|
||||
achievements(@CurrentUser() user: Actor, @Query('userId') userId?: string) {
|
||||
return this.game.achievements(user, userId);
|
||||
}
|
||||
|
||||
@Get('rewards')
|
||||
rewards(@CurrentUser() user: Actor) {
|
||||
return this.game.rewards(user);
|
||||
}
|
||||
|
||||
@Post('rewards/redeem')
|
||||
redeem(@CurrentUser() user: Actor, @Body() dto: RedeemDto) {
|
||||
return this.game.redeem(user, dto.rewardId, dto.note);
|
||||
}
|
||||
|
||||
@Get('redemptions')
|
||||
redemptions(@CurrentUser() user: Actor) {
|
||||
return this.game.myRedemptions(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { GamificationController } from './gamification.controller';
|
||||
import { GamificationAdminController } from './gamification-admin.controller';
|
||||
import { GamificationService } from './gamification.service';
|
||||
import { GamificationAdminService } from './gamification-admin.service';
|
||||
import { GamificationCronService } from './gamification-cron.service';
|
||||
|
||||
/** سراسری است تا سرویسهای دیگر بدون import ماژول، رویداد امتیاز بفرستند */
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [GamificationController, GamificationAdminController],
|
||||
providers: [GamificationService, GamificationAdminService, GamificationCronService],
|
||||
exports: [GamificationService],
|
||||
})
|
||||
export class GamificationModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { InventoryService } from '../inventory/inventory.service';
|
||||
import { CommissionsService } from '../finance/commissions.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -52,6 +53,7 @@ export class InvoicesService {
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly inventory: InventoryService,
|
||||
private readonly commissions: CommissionsService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -477,7 +479,7 @@ export class InvoicesService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const paymentId = await this.prisma.$transaction(async (tx) => {
|
||||
const payment = await tx.payment.create({
|
||||
data: {
|
||||
invoiceId,
|
||||
@@ -525,6 +527,7 @@ export class InvoicesService {
|
||||
data: { status: 'issued', issuedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return payment.id;
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
@@ -546,6 +549,16 @@ export class InvoicesService {
|
||||
};
|
||||
await this.automations.runTrigger('invoice_paid', paidCtx);
|
||||
await this.messageAutomations.onEvent('invoice_paid', paidCtx);
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'PAYMENT_REGISTERED',
|
||||
entityType: 'invoice',
|
||||
entityId: row.id,
|
||||
entityTitle: paidCtx.title,
|
||||
eventKey: paymentId,
|
||||
facts: { amount: dto.amount },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -666,6 +679,15 @@ export class InvoicesService {
|
||||
};
|
||||
await this.automations.runTrigger('invoice_issued', ctx);
|
||||
await this.messageAutomations.onEvent('invoice_issued', ctx);
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId: row.owner?.id,
|
||||
event: 'INVOICE_ISSUED',
|
||||
entityType: 'invoice',
|
||||
entityId: row.id,
|
||||
entityTitle: ctx.title,
|
||||
facts: { amount: row.payableAmount },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
ConvertLeadResult,
|
||||
LeadBoardColumnDto,
|
||||
@@ -30,6 +31,7 @@ import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const LOGO_EXT: Record<string, string> = {
|
||||
@@ -77,8 +79,27 @@ export class LeadsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
private gameEvent(
|
||||
actor: Actor,
|
||||
event: Parameters<GamificationService['emit']>[0]['event'],
|
||||
lead: { id: string; title: string },
|
||||
extra: { userId?: string | null; eventKey?: string; facts?: Record<string, unknown> } = {},
|
||||
) {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: extra.userId ?? actor.userId,
|
||||
event,
|
||||
entityType: 'lead',
|
||||
entityId: lead.id,
|
||||
entityTitle: lead.title,
|
||||
eventKey: extra.eventKey,
|
||||
facts: extra.facts,
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
opts: {
|
||||
@@ -325,6 +346,12 @@ export class LeadsService {
|
||||
{ assigneeId },
|
||||
);
|
||||
}
|
||||
this.gameEvent(actor, 'LEAD_CREATED', row, {
|
||||
facts: {
|
||||
source: row.source,
|
||||
amount: row.estimatedValue == null ? null : Number(row.estimatedValue),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -436,6 +463,10 @@ export class LeadsService {
|
||||
'stage_changed',
|
||||
`وضعیت به «${row.stage.name}» تغییر کرد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${row.stageId}`,
|
||||
facts: { stageName: row.stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
@@ -446,7 +477,7 @@ export class LeadsService {
|
||||
stageId: string,
|
||||
position?: number,
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const before = await this.findAccessible(actor, id);
|
||||
const stage = await this.prisma.leadStage.findFirst({
|
||||
where: { id: stageId, organizationId: actor.organizationId },
|
||||
});
|
||||
@@ -469,6 +500,12 @@ export class LeadsService {
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (before.stageId !== stageId) {
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -637,7 +674,7 @@ export class LeadsService {
|
||||
stageId?: string;
|
||||
},
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const before = await this.findAccessible(actor, id);
|
||||
const title = dto.outcome
|
||||
? `تماس ثبت شد — ${dto.outcome}`
|
||||
: 'تماس ثبت شد';
|
||||
@@ -658,6 +695,16 @@ export class LeadsService {
|
||||
data,
|
||||
include: leadInclude,
|
||||
});
|
||||
this.gameEvent(actor, 'CALL_LOGGED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: !!dto.body?.trim() },
|
||||
});
|
||||
if (dto.stageId && dto.stageId !== before.stageId) {
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${dto.stageId}`,
|
||||
facts: { stageName: row.stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -665,6 +712,10 @@ export class LeadsService {
|
||||
await this.findAccessible(actor, id);
|
||||
await this.addEvent(actor, id, 'note', 'یادداشت', body);
|
||||
const row = await this.findAccessible(actor, id);
|
||||
this.gameEvent(actor, 'NOTE_CREATED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: body.trim().length > 0 },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -689,6 +740,10 @@ export class LeadsService {
|
||||
data: { nextFollowUpAt: at },
|
||||
include: leadInclude,
|
||||
});
|
||||
this.gameEvent(actor, 'FOLLOW_UP_CREATED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: !!body?.trim() },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -808,6 +863,14 @@ export class LeadsService {
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
this.gameEvent(actor, 'LEAD_CONVERTED', row, {
|
||||
userId: lead.assigneeId ?? actor.userId,
|
||||
facts: {
|
||||
source: lead.source,
|
||||
amount: lead.estimatedValue == null ? null : Number(lead.estimatedValue),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
lead: this.toDto(row),
|
||||
personId: person.id,
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as path from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
type Actor,
|
||||
noteAccessWhere,
|
||||
@@ -110,6 +111,7 @@ export class NotesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -229,6 +231,15 @@ export class NotesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId,
|
||||
event: 'NOTE_CREATED',
|
||||
entityType: 'note',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title ?? row.body.slice(0, 60),
|
||||
facts: { hasDescription: row.body.trim().length >= 20 },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
@@ -49,6 +50,7 @@ export class DealsService {
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
@@ -188,6 +190,15 @@ export class DealsService {
|
||||
};
|
||||
await this.automations.runTrigger('deal_created', createdCtx);
|
||||
await this.messageAutomations.onEvent('deal_created', createdCtx);
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'DEAL_CREATED',
|
||||
entityType: 'deal',
|
||||
entityId: created.id,
|
||||
entityTitle: created.title,
|
||||
facts: { amount: created.amount == null ? null : Number(created.amount) },
|
||||
});
|
||||
this.webhooks.emit(organizationId, 'deal.created', {
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
@@ -390,6 +401,24 @@ export class DealsService {
|
||||
};
|
||||
await this.automations.runTrigger('deal_stage_changed', ctx);
|
||||
await this.messageAutomations.onEvent('deal_stage_changed', ctx);
|
||||
const gameBase = {
|
||||
organizationId,
|
||||
userId: deal.ownerId ?? actor.userId,
|
||||
entityType: 'deal',
|
||||
entityId: deal.id,
|
||||
entityTitle: deal.title,
|
||||
facts: {
|
||||
stageName: stage.name,
|
||||
amount: row.amount == null ? null : Number(row.amount),
|
||||
},
|
||||
};
|
||||
this.game.emit({ ...gameBase, event: 'DEAL_STAGE_CHANGED', eventKey: `${deal.id}:${stage.id}` });
|
||||
if (status === 'won' && deal.status !== 'won') {
|
||||
this.game.emit({ ...gameBase, event: 'DEAL_WON' });
|
||||
}
|
||||
if (status === 'lost' && deal.status !== 'lost') {
|
||||
this.game.emit({ ...gameBase, event: 'DEAL_LOST' });
|
||||
}
|
||||
if (status === 'won' && deal.status !== 'won') {
|
||||
await this.automations.runTrigger('deal_won', ctx);
|
||||
await this.messageAutomations.onEvent('deal_won', ctx);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
type QuoteActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -29,7 +30,10 @@ type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>;
|
||||
|
||||
@Injectable()
|
||||
export class QuotesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
@@ -183,7 +187,17 @@ export class QuotesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
const quote = this.toDto(row);
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId,
|
||||
event: 'QUOTE_CREATED',
|
||||
entityType: 'quote',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: { amount: quote.payableAmount },
|
||||
});
|
||||
return quote;
|
||||
}
|
||||
|
||||
async update(
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from './routine-mapper';
|
||||
import { DEFAULT_REMIND_MINUTES } from './routine-generator.service';
|
||||
import { RoutineStatsService, effectiveTotal } from './routine-stats.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
addDays,
|
||||
dayKeyToDate,
|
||||
@@ -55,6 +56,7 @@ export class RoutineTasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly stats: RoutineStatsService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
scope(actor: Actor): Promise<RoutineScope> {
|
||||
@@ -395,6 +397,19 @@ export class RoutineTasksService {
|
||||
{ status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote },
|
||||
[{ type: 'status_changed', note: note?.trim() || undefined }],
|
||||
);
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: row.assigneeId,
|
||||
event: row.routineId ? 'ROUTINE_COMPLETED' : 'TASK_COMPLETED',
|
||||
entityType: 'routine_task',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: {
|
||||
onTime: row.status !== 'overdue' && (!row.dueAt || Date.now() <= row.dueAt.getTime()),
|
||||
isMandatory: row.isMandatory,
|
||||
priority: row.priority,
|
||||
},
|
||||
});
|
||||
return toTaskDto(updated);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -45,6 +46,7 @@ export class TicketsService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -200,6 +202,15 @@ export class TicketsService {
|
||||
};
|
||||
await this.automations.runTrigger('ticket_created', createdCtx);
|
||||
await this.messageAutomations.onEvent('ticket_created', createdCtx);
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'TICKET_CREATED',
|
||||
entityType: 'ticket',
|
||||
entityId: row.id,
|
||||
entityTitle: row.subject,
|
||||
facts: { priority: row.priority },
|
||||
});
|
||||
|
||||
return this.toDto(row);
|
||||
}
|
||||
@@ -319,6 +330,15 @@ export class TicketsService {
|
||||
};
|
||||
await this.automations.runTrigger('ticket_resolved', resolvedCtx);
|
||||
await this.messageAutomations.onEvent('ticket_resolved', resolvedCtx);
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: (data.assigneeId as string | null | undefined) ?? existing.assigneeId ?? actor.userId,
|
||||
event: 'TICKET_RESOLVED',
|
||||
entityType: 'ticket',
|
||||
entityId: existing.id,
|
||||
entityTitle: existing.subject,
|
||||
facts: { priority: (dto.priority ?? existing.priority) as string },
|
||||
});
|
||||
}
|
||||
|
||||
return this.get(actor, id);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_LEVELS,
|
||||
evaluateCondition,
|
||||
jalaliMonthKeyOf,
|
||||
levelFor,
|
||||
limitBlockReason,
|
||||
matchesConditions,
|
||||
needsUsage,
|
||||
normalizeLevels,
|
||||
periodStart,
|
||||
previousPeriod,
|
||||
rankRows,
|
||||
routineStreak,
|
||||
sanitizeConditions,
|
||||
} from '../src/gamification/gamification-engine';
|
||||
|
||||
/** پنجشنبه ۱۶ مهر ۱۴۰۵، ساعت ۱۲:۳۰ تهران */
|
||||
const NOW = new Date('2026-10-08T09:00:00Z');
|
||||
|
||||
describe('gamification conditions', () => {
|
||||
it('compares numbers (also numeric strings with commas)', () => {
|
||||
assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 1000 }, { amount: 1500 }), true);
|
||||
assert.equal(evaluateCondition({ field: 'amount', op: 'gte', value: '1,500' }, { amount: 1500 }), true);
|
||||
assert.equal(evaluateCondition({ field: 'amount', op: 'lt', value: 1000 }, { amount: 1500 }), false);
|
||||
assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 10 }, {}), false);
|
||||
});
|
||||
|
||||
it('handles text, in-lists and booleans', () => {
|
||||
assert.equal(evaluateCondition({ field: 'source', op: 'eq', value: 'Instagram' }, { source: 'instagram ' }), true);
|
||||
assert.equal(evaluateCondition({ field: 'source', op: 'neq', value: 'site' }, { source: 'instagram' }), true);
|
||||
assert.equal(evaluateCondition({ field: 'stageName', op: 'contains', value: 'مذاکره' }, { stageName: 'در حال مذاکره' }), true);
|
||||
assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high، urgent' }, { priority: 'urgent' }), true);
|
||||
assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high,urgent' }, { priority: 'low' }), false);
|
||||
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_true' }, { onTime: true }), true);
|
||||
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, { onTime: false }), true);
|
||||
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, {}), true);
|
||||
});
|
||||
|
||||
it('requires all conditions (AND)', () => {
|
||||
const conds = [
|
||||
{ field: 'amount', op: 'gte' as const, value: 100 },
|
||||
{ field: 'onTime', op: 'is_true' as const },
|
||||
];
|
||||
assert.equal(matchesConditions(conds, { amount: 200, onTime: true }), true);
|
||||
assert.equal(matchesConditions(conds, { amount: 200, onTime: false }), false);
|
||||
assert.equal(matchesConditions([], {}), true);
|
||||
});
|
||||
|
||||
it('sanitizes unknown fields/ops and caps the list', () => {
|
||||
const raw = [
|
||||
{ field: 'amount', op: 'gt', value: 5 },
|
||||
{ field: 'hack', op: 'gt', value: 5 },
|
||||
{ field: 'amount', op: 'drop', value: 5 },
|
||||
{ field: 'onTime', op: 'is_true', value: { x: 1 } },
|
||||
...Array.from({ length: 20 }, () => ({ field: 'hour', op: 'lt', value: 10 })),
|
||||
];
|
||||
const out = sanitizeConditions(raw);
|
||||
assert.equal(out.length, 10);
|
||||
assert.deepEqual(out[0], { field: 'amount', op: 'gt', value: 5 });
|
||||
assert.deepEqual(out[1], { field: 'onTime', op: 'is_true', value: null });
|
||||
assert.deepEqual(sanitizeConditions('nope'), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gamification limits', () => {
|
||||
const base = {
|
||||
maxPerDay: null,
|
||||
maxCoinsPerDay: null,
|
||||
maxPerUser: null,
|
||||
cooldownMinutes: null,
|
||||
firstTimeOnly: false,
|
||||
coins: 0,
|
||||
};
|
||||
const usage = { todayCount: 0, todayCoins: 0, totalCount: 0, lastAt: null };
|
||||
|
||||
it('allows when no limit is set', () => {
|
||||
assert.equal(limitBlockReason(base, { ...usage, todayCount: 99, totalCount: 999 }, NOW), null);
|
||||
assert.equal(needsUsage(base), false);
|
||||
});
|
||||
|
||||
it('blocks per day, per user, first time and coin caps', () => {
|
||||
assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 3 }, NOW), 'max_per_day');
|
||||
assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 2 }, NOW), null);
|
||||
assert.equal(limitBlockReason({ ...base, maxPerUser: 1 }, { ...usage, totalCount: 1 }, NOW), 'max_per_user');
|
||||
assert.equal(limitBlockReason({ ...base, firstTimeOnly: true }, { ...usage, totalCount: 1 }, NOW), 'first_time_only');
|
||||
assert.equal(
|
||||
limitBlockReason({ ...base, coins: 10, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW),
|
||||
'max_coins_per_day',
|
||||
);
|
||||
assert.equal(limitBlockReason({ ...base, coins: 5, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW), null);
|
||||
assert.equal(needsUsage({ ...base, maxPerDay: 1 }), true);
|
||||
});
|
||||
|
||||
it('respects the cooldown window', () => {
|
||||
const rule = { ...base, cooldownMinutes: 30 };
|
||||
const recent = new Date(NOW.getTime() - 10 * 60_000);
|
||||
const old = new Date(NOW.getTime() - 31 * 60_000);
|
||||
assert.equal(limitBlockReason(rule, { ...usage, lastAt: recent }, NOW), 'cooldown');
|
||||
assert.equal(limitBlockReason(rule, { ...usage, lastAt: old }, NOW), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gamification periods (Jalali, Tehran)', () => {
|
||||
it('computes period starts at Tehran midnight', () => {
|
||||
assert.equal(periodStart('today', NOW)?.toISOString(), '2026-10-07T20:30:00.000Z');
|
||||
assert.equal(periodStart('week', NOW)?.toISOString(), '2026-10-02T20:30:00.000Z');
|
||||
assert.equal(periodStart('month', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z');
|
||||
assert.equal(periodStart('quarter', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z');
|
||||
assert.equal(periodStart('year', NOW)?.toISOString(), '2026-03-20T20:30:00.000Z');
|
||||
assert.equal(periodStart('all', NOW), null);
|
||||
});
|
||||
|
||||
it('computes the previous equal-sized period', () => {
|
||||
const month = previousPeriod('month', NOW);
|
||||
assert.equal(month?.from.toISOString(), '2026-08-22T20:30:00.000Z');
|
||||
assert.equal(month?.to.toISOString(), '2026-09-22T20:30:00.000Z');
|
||||
const week = previousPeriod('week', NOW);
|
||||
assert.equal(week?.from.toISOString(), '2026-09-25T20:30:00.000Z');
|
||||
assert.equal(previousPeriod('all', NOW), null);
|
||||
});
|
||||
|
||||
it('builds the Jalali month key', () => {
|
||||
assert.equal(jalaliMonthKeyOf(NOW), '1405-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gamification levels and ranking', () => {
|
||||
it('maps score to level with progress', () => {
|
||||
const l0 = levelFor(0, DEFAULT_LEVELS);
|
||||
assert.equal(l0.level, 1);
|
||||
assert.equal(l0.toNext, 250);
|
||||
const mid = levelFor(900, DEFAULT_LEVELS);
|
||||
assert.equal(mid.name, 'نقرهای');
|
||||
assert.equal(mid.progress, 50);
|
||||
const top = levelFor(10_000, DEFAULT_LEVELS);
|
||||
assert.equal(top.level, DEFAULT_LEVELS.length);
|
||||
assert.equal(top.progress, 100);
|
||||
assert.equal(top.nextName, null);
|
||||
});
|
||||
|
||||
it('normalizes custom levels', () => {
|
||||
const levels = normalizeLevels([
|
||||
{ name: 'B', minScore: 100 },
|
||||
{ name: ' ', minScore: 5 },
|
||||
{ name: 'A', minScore: 40 },
|
||||
]);
|
||||
assert.deepEqual(levels, [
|
||||
{ name: 'A', minScore: 0 },
|
||||
{ name: 'B', minScore: 100 },
|
||||
]);
|
||||
assert.equal(normalizeLevels(null), DEFAULT_LEVELS);
|
||||
});
|
||||
|
||||
it('uses competition ranking for ties', () => {
|
||||
const ranked = rankRows([
|
||||
{ userId: 'a', score: 50 },
|
||||
{ userId: 'b', score: 80 },
|
||||
{ userId: 'c', score: 50 },
|
||||
{ userId: 'd', score: 10 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
ranked.map((r) => [r.userId, r.rank]),
|
||||
[
|
||||
['b', 1],
|
||||
['a', 2],
|
||||
['c', 2],
|
||||
['d', 4],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routine streak', () => {
|
||||
const today = '2026-10-08';
|
||||
it('counts consecutive full days, skipping empty days and an unfinished today', () => {
|
||||
const days = new Map([
|
||||
['2026-10-08', { total: 3, completed: 1 }],
|
||||
['2026-10-07', { total: 2, completed: 2 }],
|
||||
['2026-10-06', { total: 0, completed: 0 }],
|
||||
['2026-10-05', { total: 1, completed: 1 }],
|
||||
['2026-10-04', { total: 2, completed: 1 }],
|
||||
['2026-10-03', { total: 2, completed: 2 }],
|
||||
]);
|
||||
assert.equal(routineStreak(days, today), 2);
|
||||
});
|
||||
|
||||
it('includes today when finished', () => {
|
||||
const days = new Map([
|
||||
['2026-10-08', { total: 1, completed: 1 }],
|
||||
['2026-10-07', { total: 1, completed: 1 }],
|
||||
]);
|
||||
assert.equal(routineStreak(days, today), 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { GamificationAchievementDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum, formatDateTime } from '@/lib/gamification';
|
||||
|
||||
const items = ref<GamificationAchievementDto[]>([]);
|
||||
const error = ref('');
|
||||
|
||||
const earned = computed(() => items.value.filter((b) => b.earned));
|
||||
const locked = computed(() =>
|
||||
items.value.filter((b) => !b.earned).sort((a, b) => b.progress - a.progress),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
items.value = (await api.get<GamificationAchievementDto[]>('/gamification/achievements')).data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری نشانها ممکن نشد.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="badges">
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<header class="sum">
|
||||
<UiIcon name="medal" :size="20" />
|
||||
<span>{{ toFaDigits(earned.length) }} از {{ toFaDigits(items.length) }} نشان را گرفتهای</span>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<article
|
||||
v-for="b in [...earned, ...locked]"
|
||||
:key="b.id"
|
||||
class="badge"
|
||||
:class="{ earned: b.earned }"
|
||||
:style="{ '--c': b.color }"
|
||||
>
|
||||
<span class="ic"><UiIcon :name="b.icon as never" :size="26" /></span>
|
||||
<h4>{{ b.name }}</h4>
|
||||
<p v-if="b.description">{{ b.description }}</p>
|
||||
<template v-if="b.earned">
|
||||
<small class="when">گرفتهشده در {{ formatDateTime(b.earnedAt!) }}</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="bar"><i :style="{ width: `${b.progress}%` }" /></div>
|
||||
<small class="when">{{ faNum(Math.min(b.current, b.threshold)) }} از {{ faNum(b.threshold) }}</small>
|
||||
</template>
|
||||
<em v-if="b.coins">+{{ faNum(b.coins) }} سکه</em>
|
||||
</article>
|
||||
<p v-if="!items.length && !error" class="empty">هنوز نشانی تعریف نشده است.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badges {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--ink-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sum :deep(svg) {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
--c: #f59e0b;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
padding: var(--sp-4) var(--sp-3);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge .ic {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.badge.earned {
|
||||
border-color: color-mix(in srgb, var(--c) 40%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--c) 10%, var(--surface)), var(--surface));
|
||||
}
|
||||
|
||||
.badge.earned .ic {
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--c) 60%, #fff), var(--c));
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px -8px var(--c);
|
||||
}
|
||||
|
||||
.badge h4 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.badge p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--c);
|
||||
}
|
||||
|
||||
.when {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { GamificationTxDto, PaginatedResponse } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiPagination } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, formatDateTime, signedFa, txIcon } from '@/lib/gamification';
|
||||
|
||||
const props = withDefaults(defineProps<{ userId?: string | null; showUser?: boolean }>(), {
|
||||
userId: null,
|
||||
showUser: false,
|
||||
});
|
||||
|
||||
const KINDS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'rule', label: 'فعالیتها' },
|
||||
{ value: 'bonus', label: 'بونوس' },
|
||||
{ value: 'achievement', label: 'نشان' },
|
||||
{ value: 'adjust', label: 'تنظیم دستی' },
|
||||
{ value: 'redeem', label: 'خرید پاداش' },
|
||||
];
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'در انتظار تأیید',
|
||||
rejected: 'رد شده',
|
||||
};
|
||||
|
||||
const kind = ref('');
|
||||
const page = ref(1);
|
||||
const data = ref<PaginatedResponse<GamificationTxDto> | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<GamificationTxDto>>('/gamification/transactions', {
|
||||
params: { kind: kind.value || undefined, page: page.value, limit: 30, userId: props.userId || undefined },
|
||||
});
|
||||
data.value = res.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری تاریخچه ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(kind, () => {
|
||||
page.value = 1;
|
||||
void load();
|
||||
});
|
||||
watch(() => props.userId, () => {
|
||||
page.value = 1;
|
||||
void load();
|
||||
});
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hist">
|
||||
<div class="chips" role="tablist">
|
||||
<button
|
||||
v-for="k in KINDS"
|
||||
:key="k.value"
|
||||
type="button"
|
||||
:class="{ on: kind === k.value }"
|
||||
@click="kind = k.value"
|
||||
>
|
||||
{{ k.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<ul class="list" :class="{ loading }">
|
||||
<li v-for="tx in data?.items ?? []" :key="tx.id" :class="tx.status">
|
||||
<span class="ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="16" /></span>
|
||||
<span class="body">
|
||||
<b>
|
||||
<template v-if="showUser">{{ tx.userName }} · </template>{{ tx.title }}
|
||||
</b>
|
||||
<small>
|
||||
<RouterLink v-if="tx.link && tx.entityTitle" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
|
||||
<template v-else-if="tx.entityTitle">{{ tx.entityTitle }}</template>
|
||||
<template v-if="tx.entityTitle"> · </template>{{ formatDateTime(tx.createdAt) }}
|
||||
</small>
|
||||
<small v-if="tx.reason" class="reason">
|
||||
دلیل: {{ tx.reason }}<template v-if="tx.createdByName"> — {{ tx.createdByName }}</template>
|
||||
</small>
|
||||
</span>
|
||||
<span v-if="STATUS_LABELS[tx.status]" class="st">{{ STATUS_LABELS[tx.status] }}</span>
|
||||
<span class="pts">
|
||||
<b v-if="tx.score" :class="{ neg: tx.score < 0 }">{{ signedFa(tx.score) }} <small>امتیاز</small></b>
|
||||
<em v-if="tx.coins" :class="{ neg: tx.coins < 0 }">{{ signedFa(tx.coins) }} <small>سکه</small></em>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="data && !data.items.length" class="empty">موردی ثبت نشده است.</li>
|
||||
</ul>
|
||||
|
||||
<UiPagination
|
||||
v-if="data && data.total > data.limit"
|
||||
v-model:page="page"
|
||||
:total-items="data.total"
|
||||
:page-size="data.limit"
|
||||
@change="load"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hist {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chips button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chips button.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list li.rejected {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.ic.bonus,
|
||||
.ic.achievement {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.ic.redeem,
|
||||
.ic.refund {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.ic.adjust {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body a {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.reason {
|
||||
color: var(--ink-secondary) !important;
|
||||
}
|
||||
|
||||
.st {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rejected .st {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pts b {
|
||||
color: var(--success-deep);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pts em {
|
||||
font-style: normal;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.pts small {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pts .neg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list li {
|
||||
padding: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-basis: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.pts {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,528 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { GamificationRewardDto, GamificationTxDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { faNum, signedFa, timeAgo, txIcon } from '@/lib/gamification';
|
||||
import ClubLeaderboard from './ClubLeaderboard.vue';
|
||||
import { useClub } from './club-context';
|
||||
|
||||
const emit = defineEmits<{ go: ['store' | 'history' | 'badges' | 'teams'] }>();
|
||||
|
||||
const { me } = useClub();
|
||||
const rewards = ref<GamificationRewardDto[]>([]);
|
||||
const feed = ref<GamificationTxDto[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const [r, f] = await Promise.allSettled([
|
||||
api.get<GamificationRewardDto[]>('/gamification/rewards'),
|
||||
api.get<GamificationTxDto[]>('/gamification/feed'),
|
||||
]);
|
||||
if (r.status === 'fulfilled') rewards.value = r.value.data.slice(0, 3);
|
||||
if (f.status === 'fulfilled') feed.value = f.value.data.slice(0, 8);
|
||||
});
|
||||
|
||||
const level = computed(() => me.value?.level ?? null);
|
||||
|
||||
/** پیام انگیزشی؛ همیشه مثبت و بدون مقایسهٔ تحقیرآمیز */
|
||||
const motivation = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m) return null;
|
||||
if (m.rank === 1 && m.periodScore > 0) {
|
||||
return { title: 'عالی!', text: 'تو صدرنشین این ماهی. جایگاهت را حفظ کن!' };
|
||||
}
|
||||
if (m.periodScore > 0 && m.periodScore >= m.personalBest) {
|
||||
return { title: 'رکورد جدید!', text: `${faNum(m.periodScore)} امتیاز — بهترین ماه تو تا امروز.` };
|
||||
}
|
||||
if (m.streakDays >= 2) {
|
||||
return { title: 'عالی!', text: `${toFaDigits(m.streakDays)} روز پیاپی همهٔ کارهای روتین را کامل کردی.` };
|
||||
}
|
||||
if (m.gapToNext != null && m.gapToNext > 0 && m.rank) {
|
||||
return {
|
||||
title: 'نزدیکی!',
|
||||
text: `فقط ${faNum(m.gapToNext)} امتیاز تا رتبهٔ ${toFaDigits(m.rank - 1)} فاصله داری.`,
|
||||
};
|
||||
}
|
||||
if (m.todayScore > 0) {
|
||||
return { title: 'آفرین!', text: `امروز ${faNum(m.todayScore)} امتیاز گرفتی. ادامه بده!` };
|
||||
}
|
||||
return { title: 'شروع کن!', text: 'با ثبت تماس، پیگیری یا یک لید جدید اولین امتیاز امروز را بگیر.' };
|
||||
});
|
||||
|
||||
function rewardProgress(r: GamificationRewardDto) {
|
||||
const coins = me.value?.coins ?? 0;
|
||||
return Math.min(100, Math.round((coins / Math.max(1, r.cost)) * 100));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<aside class="col side">
|
||||
<section class="card level-card">
|
||||
<header class="card-h">
|
||||
<h3>سطح و پیشرفت شما</h3>
|
||||
<button type="button" class="link" @click="emit('go', 'badges')">نشانها</button>
|
||||
</header>
|
||||
<div v-if="level" class="level">
|
||||
<div class="ring" :style="{ '--p': `${level.progress}` }">
|
||||
<span>
|
||||
<small>سطح</small>
|
||||
<b>{{ toFaDigits(level.level) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div class="level-info">
|
||||
<strong>{{ level.name }}</strong>
|
||||
<span>{{ toFaDigits(level.progress) }}٪ تا سطح بعد</span>
|
||||
<small v-if="level.toNext != null">
|
||||
تا «{{ level.nextName }}»: {{ faNum(level.toNext) }} امتیاز
|
||||
</small>
|
||||
<small v-else>بالاترین سطح باشگاه!</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-stats">
|
||||
<div>
|
||||
<b>{{ faNum(me?.personalBest ?? 0) }}</b>
|
||||
<span>رکورد ماهانه</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ toFaDigits(me?.streakDays ?? 0) }}</b>
|
||||
<span>روز پیاپی</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ toFaDigits(me?.badgesEarned ?? 0) }}</b>
|
||||
<span>نشان</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-h">
|
||||
<h3>پاداشهای قابل دریافت</h3>
|
||||
<button type="button" class="link" @click="emit('go', 'store')">همه</button>
|
||||
</header>
|
||||
<ul class="rewards">
|
||||
<li v-for="r in rewards" :key="r.id" :style="{ '--c': r.color }">
|
||||
<span class="r-ic"><UiIcon :name="r.icon as never" :size="18" /></span>
|
||||
<span class="r-body">
|
||||
<b>{{ r.name }}</b>
|
||||
<span class="r-bar"><i :style="{ width: `${rewardProgress(r)}%` }" /></span>
|
||||
</span>
|
||||
<em>{{ faNum(r.cost) }}<small>سکه</small></em>
|
||||
</li>
|
||||
<li v-if="!rewards.length" class="empty">هنوز پاداشی تعریف نشده است.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="motivation" class="card cheer">
|
||||
<UiIcon name="sparkle" :size="22" />
|
||||
<div>
|
||||
<strong>{{ motivation.title }}</strong>
|
||||
<p>{{ motivation.text }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="col center">
|
||||
<ClubLeaderboard />
|
||||
</div>
|
||||
|
||||
<aside class="col side">
|
||||
<section class="card">
|
||||
<header class="card-h">
|
||||
<h3>آخرین امتیازات شما</h3>
|
||||
<button type="button" class="link" @click="emit('go', 'history')">تاریخچه</button>
|
||||
</header>
|
||||
<ul class="feed">
|
||||
<li v-for="tx in me?.recent ?? []" :key="tx.id" :class="{ pending: tx.status === 'pending' }">
|
||||
<span class="f-ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="15" /></span>
|
||||
<span class="f-body">
|
||||
<b>{{ tx.title }}</b>
|
||||
<small>
|
||||
<RouterLink v-if="tx.link && tx.entityTitle" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
|
||||
<template v-else-if="tx.entityTitle">{{ tx.entityTitle }}</template>
|
||||
<template v-if="tx.entityTitle"> · </template>{{ timeAgo(tx.createdAt) }}
|
||||
<template v-if="tx.status === 'pending'"> · در انتظار تأیید</template>
|
||||
</small>
|
||||
</span>
|
||||
<span class="f-pts">
|
||||
<b v-if="tx.score" :class="{ neg: tx.score < 0 }">{{ signedFa(tx.score) }}</b>
|
||||
<small v-if="tx.coins" class="coin" :class="{ neg: tx.coins < 0 }">{{ signedFa(tx.coins) }} سکه</small>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!me?.recent.length" class="empty">
|
||||
هنوز امتیازی نگرفتهای. ثبت لید، تماس یا انجام کارهای روتین امتیاز دارد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-h">
|
||||
<h3>فعالیت همکاران</h3>
|
||||
</header>
|
||||
<ul class="feed compact">
|
||||
<li v-for="tx in feed" :key="tx.id">
|
||||
<span class="f-ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="14" /></span>
|
||||
<span class="f-body">
|
||||
<b>{{ tx.userName }}</b>
|
||||
<small>{{ tx.title }} · {{ timeAgo(tx.createdAt) }}</small>
|
||||
</span>
|
||||
<span class="f-pts"><b>{{ signedFa(tx.score) }}</b></span>
|
||||
</li>
|
||||
<li v-if="!feed.length" class="empty">هنوز فعالیتی ثبت نشده است.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr) minmax(250px, 320px);
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.card-h h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent-hover);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ring {
|
||||
--p: 0;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--accent-soft) 0);
|
||||
}
|
||||
|
||||
.ring span {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ring small {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ring b {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.level-info strong {
|
||||
font-size: 1.05rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.level-info span {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--accent-deep);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.level-info small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mini-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
border-top: 1px solid var(--line-light);
|
||||
padding-top: var(--sp-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mini-stats div {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.mini-stats b {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mini-stats span {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.rewards,
|
||||
.feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.rewards li {
|
||||
--c: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line-light);
|
||||
background: color-mix(in srgb, var(--c) 6%, var(--surface));
|
||||
}
|
||||
|
||||
.r-ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 16%, transparent);
|
||||
}
|
||||
|
||||
.r-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.r-body b {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.r-bar {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.r-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--c);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.rewards em {
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-size: var(--fs-sm);
|
||||
color: #b45309;
|
||||
display: grid;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.rewards em small {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cheer {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
background: linear-gradient(145deg, #ecfdf5, #ccfbf1);
|
||||
border-color: #99f6e4;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.cheer strong {
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.cheer p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #115e59;
|
||||
}
|
||||
|
||||
.feed li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px dashed var(--line-light);
|
||||
}
|
||||
|
||||
.feed li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.feed li.pending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.f-ic {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.f-ic.bonus,
|
||||
.f-ic.achievement {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.f-ic.redeem,
|
||||
.f-ic.refund {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.f-ic.adjust {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.f-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.f-body b {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.f-body small {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.f-body a {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.f-pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.f-pts b {
|
||||
font-weight: 800;
|
||||
color: var(--success-deep);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.f-pts .coin {
|
||||
font-size: 0.68rem;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.f-pts .neg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.feed.compact li {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border: 0 !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.center {
|
||||
grid-column: 1 / -1;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
|
||||
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
|
||||
const PERIODS: GamificationPeriod[] = ['today', 'week', 'month', 'quarter', 'year', 'all'];
|
||||
|
||||
const period = ref<GamificationPeriod>('month');
|
||||
const board = ref<GamificationLeaderboardDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<GamificationLeaderboardDto>('/gamification/leaderboard', {
|
||||
params: { period: period.value, limit: 50 },
|
||||
});
|
||||
board.value = data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری جدول امتیازات ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(period, load);
|
||||
onMounted(() => {
|
||||
void load();
|
||||
window.addEventListener('gamification:updated', load);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
|
||||
|
||||
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
|
||||
const rest = computed(() => {
|
||||
const items = board.value?.items ?? [];
|
||||
return top3.value.length ? items.slice(top3.value.length) : items;
|
||||
});
|
||||
const meOutside = computed(() => {
|
||||
const b = board.value;
|
||||
if (!b?.me) return null;
|
||||
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
|
||||
});
|
||||
|
||||
function changeLabel(e: GamificationLeaderEntryDto) {
|
||||
if (!e.change) return '';
|
||||
return `${toFaDigits(Math.abs(e.change))}`;
|
||||
}
|
||||
|
||||
const MEDALS = ['#f59e0b', '#94a3b8', '#c2410c'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="board">
|
||||
<header class="b-head">
|
||||
<div>
|
||||
<h3><UiIcon name="trophy" :size="18" /> جدول امتیازات</h3>
|
||||
<small v-if="board">{{ toFaDigits(board.totalUsers) }} همکار در رقابت</small>
|
||||
</div>
|
||||
<div class="periods" role="tablist">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="period === p"
|
||||
:class="{ on: period === p }"
|
||||
@click="period = p"
|
||||
>
|
||||
{{ GAMIFICATION_PERIOD_LABELS[p] }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div v-if="top3.length" class="top3" :class="{ loading }">
|
||||
<div
|
||||
v-for="(e, i) in top3"
|
||||
:key="e.userId"
|
||||
class="t-card"
|
||||
:class="{ me: e.isMe }"
|
||||
:style="{ '--m': MEDALS[i] }"
|
||||
>
|
||||
<span class="t-rank">{{ toFaDigits(e.rank) }}</span>
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="46" />
|
||||
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
|
||||
<small>{{ e.levelName }}</small>
|
||||
<b>{{ faNum(e.score) }} <span>امتیاز</span></b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="rows" :class="{ loading }">
|
||||
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
|
||||
<span class="rank">{{ toFaDigits(e.rank) }}</span>
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="32" />
|
||||
<span class="who">
|
||||
<b>{{ e.isMe ? 'شما' : e.fullName }}</b>
|
||||
<small>{{ e.levelName }}</small>
|
||||
</span>
|
||||
<span v-if="e.change" class="chg" :class="e.change > 0 ? 'up' : 'down'">
|
||||
{{ e.change > 0 ? '▲' : '▼' }}{{ changeLabel(e) }}
|
||||
</span>
|
||||
<span class="score">{{ faNum(e.score) }}</span>
|
||||
</li>
|
||||
<template v-if="meOutside">
|
||||
<li class="sep" aria-hidden="true">⋯</li>
|
||||
<li class="me">
|
||||
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
|
||||
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="32" />
|
||||
<span class="who">
|
||||
<b>شما</b>
|
||||
<small>{{ meOutside.levelName }}</small>
|
||||
</span>
|
||||
<span v-if="meOutside.change" class="chg" :class="meOutside.change > 0 ? 'up' : 'down'">
|
||||
{{ meOutside.change > 0 ? '▲' : '▼' }}{{ changeLabel(meOutside) }}
|
||||
</span>
|
||||
<span class="score">{{ faNum(meOutside.score) }}</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b-head h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.b-head h3 :deep(svg) {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.b-head small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.periods {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.periods::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.periods button {
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.periods button.on {
|
||||
background: var(--surface);
|
||||
color: var(--accent-deep);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.top3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.t-card {
|
||||
--m: #f59e0b;
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 3px;
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--m) 35%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--m) 12%, var(--surface)), var(--surface));
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.t-card.me {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.t-rank {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.t-card strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.t-card small {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.t-card b {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.t-card b span {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rows li:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.rows li.me {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-soft);
|
||||
}
|
||||
|
||||
.rows li.sep {
|
||||
justify-content: center;
|
||||
color: var(--muted-light);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.who b {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.who small {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chg {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chg.up {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.chg.down {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
min-width: 52px;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.top3 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.t-card {
|
||||
padding: var(--sp-2) 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { GamificationRuleDto } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENT_GROUP_LABELS, GAMIFICATION_EVENTS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum, txIcon } from '@/lib/gamification';
|
||||
import { conditionText } from './rule-text';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
defineEmits<{ close: [] }>();
|
||||
|
||||
const rules = ref<GamificationRuleDto[]>([]);
|
||||
const error = ref('');
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open || rules.value.length) return;
|
||||
try {
|
||||
rules.value = (await api.get<GamificationRuleDto[]>('/gamification/rules')).data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری قوانین ممکن نشد.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, GamificationRuleDto[]>();
|
||||
for (const r of rules.value) {
|
||||
const g = GAMIFICATION_EVENTS[r.event]?.group ?? 'activities';
|
||||
map.set(g, [...(map.get(g) ?? []), r]);
|
||||
}
|
||||
return [...map.entries()].map(([key, list]) => ({
|
||||
key,
|
||||
label: GAMIFICATION_EVENT_GROUP_LABELS[key as keyof typeof GAMIFICATION_EVENT_GROUP_LABELS] ?? key,
|
||||
list,
|
||||
}));
|
||||
});
|
||||
|
||||
function limits(r: GamificationRuleDto): string[] {
|
||||
const out: string[] = [];
|
||||
if (r.maxPerDay) out.push(`حداکثر ${toFaDigits(r.maxPerDay)} بار در روز`);
|
||||
if (r.maxCoinsPerDay) out.push(`حداکثر ${faNum(r.maxCoinsPerDay)} سکه در روز`);
|
||||
if (r.maxPerUser) out.push(`حداکثر ${toFaDigits(r.maxPerUser)} بار در کل`);
|
||||
if (r.cooldownMinutes) out.push(`فاصله حداقل ${toFaDigits(r.cooldownMinutes)} دقیقه`);
|
||||
if (r.oncePerEntity) out.push('یک بار برای هر پرونده');
|
||||
if (r.firstTimeOnly) out.push('فقط بار اول');
|
||||
if (r.requiresApproval) out.push('نیاز به تأیید مدیر');
|
||||
return out;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="open" title="قوانین و نحوه امتیازدهی" @close="$emit('close')">
|
||||
<div class="rules">
|
||||
<p class="intro">
|
||||
<b>امتیاز</b> رتبه و سطح شما را تعیین میکند و هرگز خرج نمیشود. <b>سکه</b> را میتوانی در فروشگاه
|
||||
پاداش خرج کنی. هر کار فقط یک بار امتیاز میگیرد و محدودیتها جلوی ثبتهای تکراری را میگیرند.
|
||||
</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<section v-for="g in groups" :key="g.key" class="group">
|
||||
<h4>{{ g.label }}</h4>
|
||||
<ul>
|
||||
<li v-for="r in g.list" :key="r.id">
|
||||
<span class="ic"><UiIcon :name="txIcon({ kind: 'rule', event: r.event })" :size="15" /></span>
|
||||
<span class="body">
|
||||
<b>{{ r.name }}</b>
|
||||
<small v-if="r.conditions.length">{{ r.conditions.map(conditionText).join(' و ') }}</small>
|
||||
<small v-if="limits(r).length" class="lim">{{ limits(r).join(' · ') }}</small>
|
||||
</span>
|
||||
<span class="pts">
|
||||
<b v-if="r.score">+{{ faNum(r.score) }}</b>
|
||||
<em v-if="r.coins">+{{ faNum(r.coins) }} سکه</em>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rules {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.group h4 {
|
||||
margin: 0 0 var(--sp-1);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.group ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.group li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lim {
|
||||
color: var(--warning-deep) !important;
|
||||
}
|
||||
|
||||
.pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pts b {
|
||||
color: var(--success-deep);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pts em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { GamificationRedemptionDto, GamificationRewardDto } from '@mento/shared';
|
||||
import { GAMIFICATION_REDEMPTION_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiModal, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, faNum, formatDateTime } from '@/lib/gamification';
|
||||
import { useClub } from './club-context';
|
||||
|
||||
const { me, reload } = useClub();
|
||||
const rewards = ref<GamificationRewardDto[]>([]);
|
||||
const mine = ref<GamificationRedemptionDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const picked = ref<GamificationRewardDto | null>(null);
|
||||
const note = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
const coins = computed(() => me.value?.coins ?? 0);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
api.get<GamificationRewardDto[]>('/gamification/rewards'),
|
||||
api.get<GamificationRedemptionDto[]>('/gamification/redemptions'),
|
||||
]);
|
||||
rewards.value = r.data;
|
||||
mine.value = m.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری فروشگاه ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function open(r: GamificationRewardDto) {
|
||||
ok.value = '';
|
||||
error.value = '';
|
||||
note.value = '';
|
||||
picked.value = r;
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!picked.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.post<GamificationRedemptionDto>('/gamification/rewards/redeem', {
|
||||
rewardId: picked.value.id,
|
||||
note: note.value.trim() || null,
|
||||
});
|
||||
ok.value =
|
||||
data.status === 'pending'
|
||||
? `درخواست «${data.rewardName}» ثبت شد و پس از تأیید مدیر تحویل میشود.`
|
||||
: `«${data.rewardName}» برای شما ثبت شد.`;
|
||||
picked.value = null;
|
||||
await Promise.all([load(), reload()]);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت درخواست ممکن نشد.');
|
||||
picked.value = null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="store">
|
||||
<div class="wallet">
|
||||
<UiIcon name="dollar" :size="20" />
|
||||
<span>موجودی شما: <b>{{ faNum(coins) }}</b> سکه</span>
|
||||
<small>سکه را با انجام فعالیتها، نشانها و بونوسها جمع کن.</small>
|
||||
</div>
|
||||
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div class="grid">
|
||||
<article
|
||||
v-for="r in rewards"
|
||||
:key="r.id"
|
||||
class="reward"
|
||||
:style="{ '--c': r.color }"
|
||||
:class="{ locked: coins < r.cost }"
|
||||
>
|
||||
<span class="ic"><UiIcon :name="r.icon as never" :size="26" /></span>
|
||||
<h4>{{ r.name }}</h4>
|
||||
<p v-if="r.description">{{ r.description }}</p>
|
||||
<div class="price">
|
||||
<b>{{ faNum(r.cost) }}</b>
|
||||
<small>سکه</small>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: `${Math.min(100, Math.round((coins / r.cost) * 100))}%` }" /></div>
|
||||
<small v-if="r.stock != null" class="stock">موجودی: {{ faNum(r.stock) }}</small>
|
||||
<UiButton
|
||||
size="sm"
|
||||
block
|
||||
:variant="coins >= r.cost ? 'primary' : 'outline'"
|
||||
:disabled="coins < r.cost || r.stock === 0"
|
||||
@click="open(r)"
|
||||
>
|
||||
{{ coins >= r.cost ? 'دریافت پاداش' : `${faNum(r.cost - coins)} سکه دیگر` }}
|
||||
</UiButton>
|
||||
</article>
|
||||
<p v-if="!rewards.length" class="empty">هنوز پاداشی تعریف نشده است.</p>
|
||||
</div>
|
||||
|
||||
<section v-if="mine.length" class="mine">
|
||||
<h3>درخواستهای من</h3>
|
||||
<ul>
|
||||
<li v-for="m in mine" :key="m.id">
|
||||
<UiIcon :name="m.rewardIcon as never" :size="16" />
|
||||
<span class="m-body">
|
||||
<b>{{ m.rewardName }}</b>
|
||||
<small>{{ formatDateTime(m.createdAt) }}<template v-if="m.decisionNote"> · {{ m.decisionNote }}</template></small>
|
||||
</span>
|
||||
<span class="cost">{{ faNum(m.cost) }} سکه</span>
|
||||
<span class="st" :class="m.status">{{ GAMIFICATION_REDEMPTION_LABELS[m.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<UiModal :open="!!picked" title="تأیید دریافت پاداش" @close="picked = null">
|
||||
<div v-if="picked" class="confirm">
|
||||
<p>
|
||||
برای دریافت <b>«{{ picked.name }}»</b> مبلغ <b>{{ faNum(picked.cost) }}</b> سکه از موجودی شما کسر میشود.
|
||||
</p>
|
||||
<p class="muted">
|
||||
موجودی پس از دریافت: {{ faNum(coins - picked.cost) }} سکه
|
||||
<template v-if="picked.requiresApproval"> — این درخواست پس از تأیید مدیر نهایی میشود و در صورت رد، سکهها برمیگردد.</template>
|
||||
</p>
|
||||
<UiTextarea v-model="note" placeholder="توضیح (اختیاری) — مثلاً زمان یا مدل دلخواه" :rows="2" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="picked = null">انصراف</UiButton>
|
||||
<UiButton :loading="busy" @click="confirm">تأیید و دریافت</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wallet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
background: linear-gradient(135deg, #fffbeb, #fef3c7);
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.wallet b {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.wallet small {
|
||||
color: #b45309;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
background: var(--success-soft);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.reward {
|
||||
--c: var(--accent);
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
align-content: start;
|
||||
padding: var(--sp-4);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 3px solid var(--c);
|
||||
box-shadow: var(--shadow-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, transparent);
|
||||
}
|
||||
|
||||
.reward h4 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reward p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.price b {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--c);
|
||||
}
|
||||
|
||||
.stock {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mine {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mine h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.mine ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.mine li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mine li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.m-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.m-body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.m-body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cost {
|
||||
font-size: var(--fs-xs);
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.st {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.st.pending {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.st.approved,
|
||||
.st.delivered {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.st.rejected {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import type { GamificationPeriod, GamificationTeamEntryDto } from '@mento/shared';
|
||||
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiSegmented } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
|
||||
const PERIODS: GamificationPeriod[] = ['week', 'month', 'quarter', 'year', 'all'];
|
||||
const options = PERIODS.map((p) => ({ value: p, label: GAMIFICATION_PERIOD_LABELS[p] }));
|
||||
|
||||
const period = ref<GamificationPeriod>('month');
|
||||
const teams = ref<GamificationTeamEntryDto[]>([]);
|
||||
const error = ref('');
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
teams.value = (
|
||||
await api.get<GamificationTeamEntryDto[]>('/gamification/leaderboard/teams', {
|
||||
params: { period: period.value },
|
||||
})
|
||||
).data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری رقابت تیمی ممکن نشد.');
|
||||
} finally {
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(period, load);
|
||||
onMounted(load);
|
||||
|
||||
function share(t: GamificationTeamEntryDto) {
|
||||
const max = Math.max(1, ...teams.value.map((x) => x.score));
|
||||
return Math.round((Math.max(0, t.score) / max) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="teams">
|
||||
<div class="bar-top">
|
||||
<UiSegmented v-model="period" :options="options" />
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<ol class="list">
|
||||
<li v-for="t in teams" :key="t.teamId" :class="{ mine: t.isMine }">
|
||||
<span class="rank" :class="`r${t.rank}`">
|
||||
<UiIcon v-if="t.rank === 1" name="crown" :size="16" />
|
||||
<template v-else>{{ toFaDigits(t.rank) }}</template>
|
||||
</span>
|
||||
<span class="body">
|
||||
<b>{{ t.name }} <small v-if="t.isMine">تیم شما</small></b>
|
||||
<span class="track"><i :style="{ width: `${share(t)}%` }" /></span>
|
||||
<small>{{ toFaDigits(t.members) }} عضو · میانگین {{ faNum(t.avgScore) }} امتیاز</small>
|
||||
</span>
|
||||
<span class="score">{{ faNum(t.score) }}</span>
|
||||
</li>
|
||||
<li v-if="loaded && !teams.length" class="empty">
|
||||
هنوز تیمی تعریف نشده است. تیمها از «تنظیمات › تیمها» ساخته میشوند.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teams {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list li.mine {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rank.r1 {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body b small {
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.body > small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), #5eead4);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: 800;
|
||||
font-size: var(--fs-md);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import type { GamificationPulseDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { faNum, txIcon } from '@/lib/gamification';
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
const MIN_GAP_MS = 8_000;
|
||||
const TOAST_MS = 5_500;
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
icon: ReturnType<typeof txIcon> | 'crown';
|
||||
title: string;
|
||||
points: string;
|
||||
rankUp: boolean;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toasts = ref<Toast[]>([]);
|
||||
let since: string | null = null;
|
||||
let rank: number | null = null;
|
||||
let lastPoll = 0;
|
||||
let stopped = false;
|
||||
let busy = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
function push(t: Omit<Toast, 'id'>) {
|
||||
const id = ++seq;
|
||||
toasts.value = [...toasts.value.slice(-3), { ...t, id }];
|
||||
setTimeout(() => dismiss(id), TOAST_MS);
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (stopped || busy || document.visibilityState !== 'visible') return;
|
||||
busy = true;
|
||||
lastPoll = Date.now();
|
||||
try {
|
||||
const { data } = await api.get<GamificationPulseDto>('/gamification/pulse', {
|
||||
params: since ? { since } : {},
|
||||
});
|
||||
const first = since == null;
|
||||
since = data.serverTime;
|
||||
if (!first) {
|
||||
for (const tx of data.items) {
|
||||
const parts = [
|
||||
tx.score ? `${tx.score > 0 ? '+' : '−'}${faNum(Math.abs(tx.score))} امتیاز` : '',
|
||||
tx.coins ? `${tx.coins > 0 ? '+' : '−'}${faNum(Math.abs(tx.coins))} سکه` : '',
|
||||
].filter(Boolean);
|
||||
push({ icon: txIcon(tx), title: tx.title, points: parts.join(' · '), rankUp: false });
|
||||
}
|
||||
if (data.rank != null && rank != null && data.rank < rank) {
|
||||
push({
|
||||
icon: 'crown',
|
||||
title: `رتبه شما در این ماه به #${toFaDigits(data.rank)} رسید!`,
|
||||
points: `${toFaDigits(rank - data.rank)} پله صعود`,
|
||||
rankUp: true,
|
||||
});
|
||||
}
|
||||
if (data.items.length) window.dispatchEvent(new CustomEvent('gamification:updated'));
|
||||
}
|
||||
rank = data.rank;
|
||||
} catch (e) {
|
||||
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
|
||||
if (status === 401 || status === 403 || status === 404) stopped = true;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastPoll > MIN_GAP_MS) void poll();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (Date.now() - lastPoll > MIN_GAP_MS) setTimeout(() => void poll(), 1_500);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void poll();
|
||||
timer = setInterval(() => void poll(), POLL_MS);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('gamification:poke', onVisible);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('gamification:poke', onVisible);
|
||||
});
|
||||
|
||||
function open(t: Toast) {
|
||||
dismiss(t.id);
|
||||
void router.push('/club');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="g-toasts" aria-live="polite">
|
||||
<TransitionGroup name="g-toast">
|
||||
<button
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="g-toast"
|
||||
:class="{ rank: t.rankUp }"
|
||||
@click="open(t)"
|
||||
>
|
||||
<span class="ic"><UiIcon :name="t.icon" :size="18" /></span>
|
||||
<span class="body">
|
||||
<b>{{ t.points || 'امتیاز جدید' }}</b>
|
||||
<small>{{ t.title }}</small>
|
||||
</span>
|
||||
<UiIcon name="star" :size="14" class="spark" />
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.g-toasts {
|
||||
position: fixed;
|
||||
inset-inline-start: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
max-width: min(340px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.g-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
color: #ecfeff;
|
||||
background: linear-gradient(135deg, #0b3b3c, #0f766e);
|
||||
box-shadow: 0 14px 30px -14px rgba(15, 81, 84, 0.9);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.g-toast.rank {
|
||||
background: linear-gradient(135deg, #92400e, #f59e0b);
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 800;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.g-toast.rank .body b {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spark {
|
||||
color: #fde68a;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.g-toast-enter-active,
|
||||
.g-toast-leave-active {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.g-toast-enter-from {
|
||||
transform: translateY(16px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.g-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.g-toasts {
|
||||
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||
inset-inline-start: 12px;
|
||||
inset-inline-end: 12px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,531 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type {
|
||||
GamificationBadgeDto,
|
||||
GamificationBadgeMetric,
|
||||
GamificationBonusDto,
|
||||
GamificationBonusKind,
|
||||
GamificationEvent,
|
||||
SaveGamificationBadgeRequest,
|
||||
SaveGamificationBonusRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
GAMIFICATION_BADGE_METRICS,
|
||||
GAMIFICATION_BONUS_KINDS,
|
||||
GAMIFICATION_EVENT_KEYS,
|
||||
GAMIFICATION_EVENTS,
|
||||
} from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, BADGE_ICONS, eventLabel, faNum } from '@/lib/gamification';
|
||||
|
||||
const COLORS = ['#f59e0b', '#0d9488', '#8b5cf6', '#ef4444', '#3b82f6', '#10b981', '#ec4899'];
|
||||
const PERIOD_LABELS = { day: 'روز', week: 'هفته', month: 'ماه' } as const;
|
||||
|
||||
const badges = ref<GamificationBadgeDto[]>([]);
|
||||
const bonuses = ref<GamificationBonusDto[]>([]);
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [b, o] = await Promise.all([
|
||||
api.get<GamificationBadgeDto[]>('/admin/gamification/badges'),
|
||||
api.get<GamificationBonusDto[]>('/admin/gamification/bonuses'),
|
||||
]);
|
||||
badges.value = b.data;
|
||||
bonuses.value = o.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری نشانها و بونوسها ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
/* نشان */
|
||||
const badgeOpen = ref(false);
|
||||
const badgeId = ref<string | null>(null);
|
||||
const badgeErr = ref('');
|
||||
const badgeBusy = ref(false);
|
||||
const bf = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'medal',
|
||||
color: COLORS[0],
|
||||
metric: 'event_count' as string,
|
||||
event: 'DEAL_WON' as string,
|
||||
threshold: '10',
|
||||
coins: '0',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
function editBadge(b: GamificationBadgeDto | null) {
|
||||
badgeId.value = b?.id ?? null;
|
||||
badgeErr.value = '';
|
||||
Object.assign(bf, {
|
||||
name: b?.name ?? '',
|
||||
description: b?.description ?? '',
|
||||
icon: b?.icon ?? 'medal',
|
||||
color: b?.color ?? COLORS[0],
|
||||
metric: b?.metric ?? 'event_count',
|
||||
event: b?.event ?? 'DEAL_WON',
|
||||
threshold: String(b?.threshold ?? 10),
|
||||
coins: String(b?.coins ?? 0),
|
||||
isActive: b?.isActive ?? true,
|
||||
});
|
||||
badgeOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveBadge() {
|
||||
const threshold = Math.round(Number(bf.threshold));
|
||||
if (bf.name.trim().length < 2 || !(threshold > 0)) {
|
||||
badgeErr.value = 'نام و آستانه معتبر وارد کنید.';
|
||||
return;
|
||||
}
|
||||
const body: SaveGamificationBadgeRequest = {
|
||||
name: bf.name.trim(),
|
||||
description: bf.description.trim() || null,
|
||||
icon: bf.icon,
|
||||
color: bf.color,
|
||||
metric: bf.metric as GamificationBadgeMetric,
|
||||
event: bf.metric === 'event_count' ? (bf.event as GamificationEvent) : null,
|
||||
threshold,
|
||||
coins: Math.max(0, Math.round(Number(bf.coins) || 0)),
|
||||
isActive: bf.isActive,
|
||||
};
|
||||
badgeBusy.value = true;
|
||||
try {
|
||||
if (badgeId.value) await api.patch(`/admin/gamification/badges/${badgeId.value}`, body);
|
||||
else await api.post('/admin/gamification/badges', body);
|
||||
badgeOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
badgeErr.value = apiError(e, 'ذخیره نشان ممکن نشد.');
|
||||
} finally {
|
||||
badgeBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* بونوس */
|
||||
const bonusOpen = ref(false);
|
||||
const bonusId = ref<string | null>(null);
|
||||
const bonusErr = ref('');
|
||||
const bonusBusy = ref(false);
|
||||
const of = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
kind: 'event_count_period' as string,
|
||||
event: 'DEAL_WON' as string,
|
||||
threshold: '10',
|
||||
period: 'month' as string,
|
||||
score: '0',
|
||||
coins: '100',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
function editBonus(b: GamificationBonusDto | null) {
|
||||
bonusId.value = b?.id ?? null;
|
||||
bonusErr.value = '';
|
||||
Object.assign(of, {
|
||||
name: b?.name ?? '',
|
||||
description: b?.description ?? '',
|
||||
kind: b?.kind ?? 'event_count_period',
|
||||
event: b?.event ?? 'DEAL_WON',
|
||||
threshold: String(b?.threshold ?? 10),
|
||||
period: b?.period ?? 'month',
|
||||
score: String(b?.score ?? 0),
|
||||
coins: String(b?.coins ?? 100),
|
||||
isActive: b?.isActive ?? true,
|
||||
});
|
||||
bonusOpen.value = true;
|
||||
}
|
||||
|
||||
const thresholdLabel = computed(() => (of.kind === 'routine_streak' ? 'هر چند روز پیاپی' : 'تعداد لازم'));
|
||||
|
||||
async function saveBonus() {
|
||||
const threshold = Math.round(Number(of.threshold));
|
||||
if (of.name.trim().length < 2 || !(threshold >= 0)) {
|
||||
bonusErr.value = 'نام و آستانه معتبر وارد کنید.';
|
||||
return;
|
||||
}
|
||||
const body: SaveGamificationBonusRequest = {
|
||||
name: of.name.trim(),
|
||||
description: of.description.trim() || null,
|
||||
kind: of.kind as GamificationBonusKind,
|
||||
event: of.kind === 'event_count_period' ? (of.event as GamificationEvent) : null,
|
||||
threshold,
|
||||
period: of.period as 'day' | 'week' | 'month',
|
||||
score: Math.round(Number(of.score) || 0),
|
||||
coins: Math.max(0, Math.round(Number(of.coins) || 0)),
|
||||
isActive: of.isActive,
|
||||
};
|
||||
bonusBusy.value = true;
|
||||
try {
|
||||
if (bonusId.value) await api.patch(`/admin/gamification/bonuses/${bonusId.value}`, body);
|
||||
else await api.post('/admin/gamification/bonuses', body);
|
||||
bonusOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
bonusErr.value = apiError(e, 'ذخیره بونوس ممکن نشد.');
|
||||
} finally {
|
||||
bonusBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(kind: 'badges' | 'bonuses', id: string, name: string) {
|
||||
if (!window.confirm(`«${name}» حذف شود؟`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/gamification/${kind}/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function bonusText(b: GamificationBonusDto): string {
|
||||
if (b.kind === 'routine_streak') return `هر ${faNum(b.threshold)} روز پیاپی انجام کامل روتین`;
|
||||
if (b.kind === 'top_of_month') return 'نفر اول امتیاز در پایان هر ماه';
|
||||
return `${faNum(b.threshold)} بار «${eventLabel(b.event)}» در یک ${PERIOD_LABELS[b.period]}`;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ach">
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<article class="card">
|
||||
<div class="head">
|
||||
<h3><UiIcon name="medal" :size="17" /> نشانها</h3>
|
||||
<UiButton size="sm" @click="editBadge(null)"><UiIcon name="plus" :size="14" /> نشان جدید</UiButton>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li v-for="b in badges" :key="b.id" :class="{ off: !b.isActive }" :style="{ '--c': b.color }">
|
||||
<span class="ic"><UiIcon :name="b.icon as never" :size="16" /></span>
|
||||
<span class="body">
|
||||
<b>{{ b.name }}</b>
|
||||
<small>
|
||||
{{ GAMIFICATION_BADGE_METRICS[b.metric] }}<template v-if="b.event"> «{{ eventLabel(b.event) }}»</template>
|
||||
≥ {{ faNum(b.threshold) }}
|
||||
<template v-if="b.coins"> · +{{ faNum(b.coins) }} سکه</template>
|
||||
</small>
|
||||
</span>
|
||||
<span class="count">{{ faNum(b.earnedCount) }} نفر</span>
|
||||
<span class="acts">
|
||||
<button type="button" title="ویرایش" @click="editBadge(b)"><UiIcon name="edit" :size="15" /></button>
|
||||
<button type="button" class="del" title="حذف" @click="remove('badges', b.id, b.name)"><UiIcon name="trash" :size="15" /></button>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!badges.length" class="empty">نشانی تعریف نشده است.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="head">
|
||||
<h3><UiIcon name="star" :size="17" /> بونوسها</h3>
|
||||
<UiButton size="sm" @click="editBonus(null)"><UiIcon name="plus" :size="14" /> بونوس جدید</UiButton>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li v-for="b in bonuses" :key="b.id" :class="{ off: !b.isActive }" style="--c: #f59e0b">
|
||||
<span class="ic"><UiIcon name="star" :size="16" /></span>
|
||||
<span class="body">
|
||||
<b>{{ b.name }}</b>
|
||||
<small>{{ bonusText(b) }}</small>
|
||||
</span>
|
||||
<span class="pts">
|
||||
<b v-if="b.score">+{{ faNum(b.score) }}</b>
|
||||
<em v-if="b.coins">+{{ faNum(b.coins) }} سکه</em>
|
||||
</span>
|
||||
<span class="count">{{ faNum(b.awardedCount) }}×</span>
|
||||
<span class="acts">
|
||||
<button type="button" title="ویرایش" @click="editBonus(b)"><UiIcon name="edit" :size="15" /></button>
|
||||
<button type="button" class="del" title="حذف" @click="remove('bonuses', b.id, b.name)"><UiIcon name="trash" :size="15" /></button>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!bonuses.length" class="empty">بونوسی تعریف نشده است.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<UiModal :open="badgeOpen" :title="badgeId ? 'ویرایش نشان' : 'نشان جدید'" @close="badgeOpen = false">
|
||||
<form class="form" @submit.prevent="saveBadge">
|
||||
<UiField label="نام" required><UiInput v-model="bf.name" maxlength="120" /></UiField>
|
||||
<UiField label="شاخص">
|
||||
<UiSelect v-model="bf.metric">
|
||||
<option v-for="(label, key) in GAMIFICATION_BADGE_METRICS" :key="key" :value="key">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField v-if="bf.metric === 'event_count'" label="رویداد">
|
||||
<UiSelect v-model="bf.event">
|
||||
<option v-for="k in GAMIFICATION_EVENT_KEYS" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<div class="row">
|
||||
<UiField label="آستانه" required><UiInput v-model="bf.threshold" type="number" /></UiField>
|
||||
<UiField label="سکه هدیه"><UiInput v-model="bf.coins" type="number" /></UiField>
|
||||
</div>
|
||||
<UiField label="توضیح"><UiTextarea v-model="bf.description" :rows="2" /></UiField>
|
||||
<div class="pick">
|
||||
<span>آیکن:</span>
|
||||
<button v-for="ic in BADGE_ICONS" :key="ic" type="button" :class="{ on: bf.icon === ic }" @click="bf.icon = ic">
|
||||
<UiIcon :name="ic" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pick">
|
||||
<span>رنگ:</span>
|
||||
<button
|
||||
v-for="c in COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ on: bf.color === c }"
|
||||
:style="{ background: c }"
|
||||
:aria-label="c"
|
||||
@click="bf.color = c"
|
||||
/>
|
||||
</div>
|
||||
<label class="ck"><input v-model="bf.isActive" type="checkbox" /> فعال</label>
|
||||
<p v-if="badgeErr" class="err">{{ badgeErr }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="badgeOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="badgeBusy" @click="saveBadge">ذخیره</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="bonusOpen" :title="bonusId ? 'ویرایش بونوس' : 'بونوس جدید'" @close="bonusOpen = false">
|
||||
<form class="form" @submit.prevent="saveBonus">
|
||||
<UiField label="نام" required><UiInput v-model="of.name" maxlength="120" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<UiSelect v-model="of.kind">
|
||||
<option v-for="(label, key) in GAMIFICATION_BONUS_KINDS" :key="key" :value="key">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<div v-if="of.kind === 'event_count_period'" class="row">
|
||||
<UiField label="رویداد">
|
||||
<UiSelect v-model="of.event">
|
||||
<option v-for="k in GAMIFICATION_EVENT_KEYS" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField label="در بازه">
|
||||
<UiSelect v-model="of.period">
|
||||
<option v-for="(label, key) in PERIOD_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="row3">
|
||||
<UiField v-if="of.kind !== 'top_of_month'" :label="thresholdLabel"><UiInput v-model="of.threshold" type="number" /></UiField>
|
||||
<UiField label="امتیاز"><UiInput v-model="of.score" type="number" /></UiField>
|
||||
<UiField label="سکه"><UiInput v-model="of.coins" type="number" /></UiField>
|
||||
</div>
|
||||
<UiField label="توضیح"><UiTextarea v-model="of.description" :rows="2" /></UiField>
|
||||
<label class="ck"><input v-model="of.isActive" type="checkbox" /> فعال</label>
|
||||
<p v-if="bonusErr" class="err">{{ bonusErr }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="bonusOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="bonusBusy" @click="saveBonus">ذخیره</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ach {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.head h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.list li {
|
||||
--c: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list li.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 15%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.pts b {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.pts em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.acts button:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.acts .del:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.row3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pick button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.pick button.on {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.pick .dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pick .dot.on {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.ck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type { OrgUserDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiField, UiInput, UiSegmented, UiSelect, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import ClubHistory from '../ClubHistory.vue';
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>();
|
||||
|
||||
type Target = 'score' | 'coins';
|
||||
const targetOptions: Array<{ value: Target; label: string }> = [
|
||||
{ value: 'score', label: 'امتیاز' },
|
||||
{ value: 'coins', label: 'سکه' },
|
||||
];
|
||||
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const target = ref<Target>('score');
|
||||
const form = reactive({ userId: '', amount: '', reason: '' });
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const busy = ref(false);
|
||||
const historyKey = ref(0);
|
||||
|
||||
const activeUsers = computed(() => users.value.filter((u) => u.isActive !== false));
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
users.value = (await api.get<OrgUserDto[]>('/users')).data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری کاربران ممکن نشد.');
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
const amount = Math.round(Number(form.amount));
|
||||
if (!form.userId) {
|
||||
error.value = 'همکار را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
if (!amount) {
|
||||
error.value = 'مقدار باید عددی غیر صفر باشد (برای کسر، منفی وارد کنید).';
|
||||
return;
|
||||
}
|
||||
if (form.reason.trim().length < 2) {
|
||||
error.value = 'دلیل تغییر الزامی است و در تاریخچه ثبت میشود.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await api.post(`/admin/gamification/adjust-${target.value}`, {
|
||||
userId: form.userId,
|
||||
amount,
|
||||
reason: form.reason.trim(),
|
||||
});
|
||||
const name = users.value.find((u) => u.id === form.userId)?.fullName ?? '';
|
||||
ok.value = `${amount > 0 ? '+' : ''}${faNum(amount)} ${target.value === 'score' ? 'امتیاز' : 'سکه'} برای ${name} ثبت شد.`;
|
||||
form.amount = '';
|
||||
form.reason = '';
|
||||
historyKey.value++;
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت تغییر ممکن نشد.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="adj">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<h3><UiIcon name="sliders" :size="17" /> افزودن یا کسر دستی</h3>
|
||||
<p class="hint">همه تغییرات دستی با نام شما و دلیل در تاریخچه ثبت میشوند و به همکار اطلاع داده میشود.</p>
|
||||
<div class="grid">
|
||||
<UiField label="همکار" required>
|
||||
<UiSelect v-model="form.userId">
|
||||
<option value="">انتخاب کنید…</option>
|
||||
<option v-for="u in activeUsers" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField label="نوع">
|
||||
<UiSegmented v-model="target" :options="targetOptions" />
|
||||
</UiField>
|
||||
<UiField label="مقدار" hint="مثبت = افزودن، منفی = کسر" required>
|
||||
<UiInput v-model="form.amount" type="number" placeholder="مثلاً 50 یا -20" />
|
||||
</UiField>
|
||||
</div>
|
||||
<UiField label="دلیل" required>
|
||||
<UiTextarea v-model="form.reason" :rows="2" placeholder="مثلاً: جبران امتیاز ثبتنشده جلسه با مشتری" :maxlength="500" />
|
||||
</UiField>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div><UiButton type="submit" :loading="busy">ثبت تغییر</UiButton></div>
|
||||
</form>
|
||||
|
||||
<article class="card">
|
||||
<h3><UiIcon name="clock" :size="17" /> تاریخچه {{ form.userId ? 'این همکار' : 'همه همکاران' }}</h3>
|
||||
<ClubHistory :key="historyKey" :user-id="form.userId || null" show-user />
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adj {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) auto minmax(0, 1fr);
|
||||
gap: var(--sp-3);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { GamificationRedemptionDto, GamificationRedemptionStatus, GamificationTxDto } from '@mento/shared';
|
||||
import { GAMIFICATION_REDEMPTION_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiSegmented } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, faNum, formatDateTime, txIcon } from '@/lib/gamification';
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>();
|
||||
|
||||
const pending = ref<GamificationTxDto[]>([]);
|
||||
const redemptions = ref<GamificationRedemptionDto[]>([]);
|
||||
const status = ref<GamificationRedemptionStatus | ''>('pending');
|
||||
const error = ref('');
|
||||
const busyId = ref('');
|
||||
|
||||
const statusOptions: Array<{ value: GamificationRedemptionStatus | ''; label: string }> = [
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'approved', label: 'تأیید شده' },
|
||||
{ value: 'delivered', label: 'تحویل شده' },
|
||||
{ value: 'rejected', label: 'رد شده' },
|
||||
{ value: '', label: 'همه' },
|
||||
];
|
||||
|
||||
async function loadPending() {
|
||||
pending.value = (await api.get<GamificationTxDto[]>('/admin/gamification/pending')).data;
|
||||
}
|
||||
|
||||
async function loadRedemptions() {
|
||||
redemptions.value = (
|
||||
await api.get<GamificationRedemptionDto[]>('/admin/gamification/redemptions', {
|
||||
params: status.value ? { status: status.value } : {},
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
await Promise.all([loadPending(), loadRedemptions()]);
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری درخواستها ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function askNote(action: string): string | null | false {
|
||||
const note = window.prompt(`توضیح برای ${action} (اختیاری):`, '');
|
||||
if (note === null) return false;
|
||||
return note.trim() || null;
|
||||
}
|
||||
|
||||
async function decideTx(tx: GamificationTxDto, approve: boolean) {
|
||||
const note = approve ? null : askNote('رد');
|
||||
if (note === false) return;
|
||||
busyId.value = tx.id;
|
||||
try {
|
||||
await api.post(`/admin/gamification/transactions/${tx.id}/${approve ? 'approve' : 'reject'}`, { note });
|
||||
pending.value = pending.value.filter((x) => x.id !== tx.id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت تصمیم ممکن نشد.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function decideRedemption(r: GamificationRedemptionDto, action: 'approve' | 'reject' | 'deliver') {
|
||||
const note = action === 'reject' ? askNote('رد (سکهها برگردانده میشود)') : null;
|
||||
if (note === false) return;
|
||||
busyId.value = r.id;
|
||||
try {
|
||||
await api.post(`/admin/gamification/redemptions/${r.id}/${action}`, { note });
|
||||
await loadRedemptions();
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت تصمیم ممکن نشد.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function onStatus(next: GamificationRedemptionStatus | '') {
|
||||
status.value = next;
|
||||
try {
|
||||
await loadRedemptions();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری درخواستها ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appr">
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<article class="card">
|
||||
<h3><UiIcon name="clock" :size="17" /> امتیازهای منتظر تأیید</h3>
|
||||
<ul v-if="pending.length" class="list">
|
||||
<li v-for="tx in pending" :key="tx.id">
|
||||
<span class="ic"><UiIcon :name="txIcon(tx)" :size="15" /></span>
|
||||
<span class="body">
|
||||
<b>{{ tx.userName }} — {{ tx.title }}</b>
|
||||
<small>
|
||||
{{ formatDateTime(tx.createdAt) }}
|
||||
<template v-if="tx.entityTitle">
|
||||
·
|
||||
<RouterLink v-if="tx.link" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
|
||||
<template v-else>{{ tx.entityTitle }}</template>
|
||||
</template>
|
||||
</small>
|
||||
</span>
|
||||
<span class="pts">
|
||||
<b>{{ tx.score > 0 ? '+' : '' }}{{ faNum(tx.score) }}</b>
|
||||
<em v-if="tx.coins">+{{ faNum(tx.coins) }} سکه</em>
|
||||
</span>
|
||||
<span class="acts">
|
||||
<UiButton size="sm" variant="success" :loading="busyId === tx.id" @click="decideTx(tx, true)">تأیید</UiButton>
|
||||
<UiButton size="sm" variant="outline" :disabled="busyId === tx.id" @click="decideTx(tx, false)">رد</UiButton>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">امتیازی منتظر تأیید نیست.</p>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="head">
|
||||
<h3><UiIcon name="gift" :size="17" /> درخواستهای پاداش</h3>
|
||||
<UiSegmented :model-value="status" :options="statusOptions" @update:model-value="onStatus" />
|
||||
</div>
|
||||
<ul v-if="redemptions.length" class="list">
|
||||
<li v-for="r in redemptions" :key="r.id">
|
||||
<span class="ic gift"><UiIcon :name="r.rewardIcon as never" :size="15" /></span>
|
||||
<span class="body">
|
||||
<b>{{ r.userName }} — {{ r.rewardName }}</b>
|
||||
<small>
|
||||
{{ formatDateTime(r.createdAt) }}
|
||||
<template v-if="r.note"> · «{{ r.note }}»</template>
|
||||
<template v-if="r.decidedByName"> · {{ r.decidedByName }}</template>
|
||||
<template v-if="r.decisionNote"> · {{ r.decisionNote }}</template>
|
||||
</small>
|
||||
</span>
|
||||
<span class="cost">{{ faNum(r.cost) }} سکه</span>
|
||||
<span class="st" :class="r.status">{{ GAMIFICATION_REDEMPTION_LABELS[r.status] }}</span>
|
||||
<span class="acts">
|
||||
<template v-if="r.status === 'pending'">
|
||||
<UiButton size="sm" variant="success" :loading="busyId === r.id" @click="decideRedemption(r, 'approve')">تأیید</UiButton>
|
||||
<UiButton size="sm" variant="outline" :disabled="busyId === r.id" @click="decideRedemption(r, 'reject')">رد</UiButton>
|
||||
</template>
|
||||
<UiButton
|
||||
v-else-if="r.status === 'approved'"
|
||||
size="sm"
|
||||
:loading="busyId === r.id"
|
||||
@click="decideRedemption(r, 'deliver')"
|
||||
>
|
||||
تحویل شد
|
||||
</UiButton>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">درخواستی در این وضعیت نیست.</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appr {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.head > :last-child {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ic.gift {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.pts b {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.pts em,
|
||||
.cost {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.st {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.st.pending {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.st.approved,
|
||||
.st.delivered {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.st.rejected {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import type { GamificationPeriod, GamificationReportDto, GamificationReportUserRow } from '@mento/shared';
|
||||
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiSegmented } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
|
||||
const PERIODS: GamificationPeriod[] = ['today', 'week', 'month', 'quarter', 'year', 'all'];
|
||||
const options = PERIODS.map((p) => ({ value: p, label: GAMIFICATION_PERIOD_LABELS[p] }));
|
||||
|
||||
const period = ref<GamificationPeriod>('month');
|
||||
const report = ref<GamificationReportDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
report.value = (
|
||||
await api.get<GamificationReportDto>('/admin/gamification/reports', { params: { period: period.value } })
|
||||
).data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری گزارش ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(period, load);
|
||||
onMounted(load);
|
||||
|
||||
const lists: Array<{ key: keyof GamificationReportDto; title: string; unit: string }> = [
|
||||
{ key: 'topActive', title: 'فعالترینها', unit: 'امتیاز' },
|
||||
{ key: 'topGrowth', title: 'بیشترین رشد', unit: 'امتیاز' },
|
||||
{ key: 'topDeals', title: 'بیشترین معامله برنده', unit: 'معامله' },
|
||||
{ key: 'topLeads', title: 'بیشترین لید', unit: 'لید' },
|
||||
{ key: 'topTasks', title: 'بیشترین کار انجامشده', unit: 'کار' },
|
||||
{ key: 'inactive', title: 'کمفعالها', unit: 'امتیاز' },
|
||||
];
|
||||
|
||||
function rows(key: keyof GamificationReportDto): GamificationReportUserRow[] {
|
||||
return (report.value?.[key] as GamificationReportUserRow[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
return Number.isInteger(v) ? faNum(v) : toFaDigits(v.toFixed(1));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rep" :class="{ loading }">
|
||||
<div class="bar">
|
||||
<UiSegmented v-model="period" :options="options" />
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<template v-if="report">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><small>امتیاز توزیعشده</small><b>{{ faNum(report.totals.score) }}</b></div>
|
||||
<div class="kpi"><small>سکه دادهشده</small><b>{{ faNum(report.totals.coinsEarned) }}</b></div>
|
||||
<div class="kpi"><small>سکه خرجشده</small><b>{{ faNum(report.totals.coinsSpent) }}</b></div>
|
||||
<div class="kpi">
|
||||
<small>همکاران فعال</small>
|
||||
<b>{{ toFaDigits(report.totals.activeUsers) }} / {{ toFaDigits(report.totals.totalUsers) }}</b>
|
||||
</div>
|
||||
<div class="kpi"><small>تراکنشها</small><b>{{ faNum(report.totals.transactions) }}</b></div>
|
||||
<div class="kpi warn">
|
||||
<small>منتظر تأیید</small>
|
||||
<b>{{ toFaDigits(report.totals.pendingApprovals + report.totals.pendingRedemptions) }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="card">
|
||||
<h3><UiIcon name="trend" :size="17" /> اثر باشگاه بر عملکرد (ROI)</h3>
|
||||
<p v-if="!report.roi.since" class="hint">تاریخ فعالسازی باشگاه ثبت نشده است.</p>
|
||||
<template v-else>
|
||||
<p class="hint">
|
||||
میانگین روزانه {{ toFaDigits(report.roi.windowDays) }} روز قبل از فعالسازی، در مقایسه با
|
||||
{{ toFaDigits(report.roi.windowDays) }} روز بعد از آن.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>شاخص</th><th>قبل</th><th>بعد</th><th>تغییر</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in report.roi.metrics" :key="m.key">
|
||||
<td>{{ m.label }}</td>
|
||||
<td>{{ fmt(m.before) }}</td>
|
||||
<td>{{ fmt(m.after) }}</td>
|
||||
<td>
|
||||
<span v-if="m.changePct != null" class="chg" :class="{ up: m.changePct > 0, down: m.changePct < 0 }">
|
||||
{{ m.changePct > 0 ? '▲' : m.changePct < 0 ? '▼' : '' }} {{ toFaDigits(Math.abs(m.changePct)) }}٪
|
||||
</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
|
||||
<div class="cols">
|
||||
<article v-for="l in lists" :key="l.key" class="card">
|
||||
<h3>{{ l.title }}</h3>
|
||||
<ol v-if="rows(l.key).length" class="rank">
|
||||
<li v-for="(r, i) in rows(l.key)" :key="r.userId">
|
||||
<span class="n">{{ toFaDigits(i + 1) }}</span>
|
||||
<span class="name">{{ r.fullName }}</span>
|
||||
<b>{{ faNum(r.value) }}</b>
|
||||
<small>{{ l.unit }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="hint">دادهای نیست.</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="card">
|
||||
<h3><UiIcon name="funnel" :size="17" /> امتیاز به تفکیک رویداد</h3>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>رویداد</th><th>تعداد</th><th>امتیاز</th><th>سکه</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in report.byEvent" :key="e.event">
|
||||
<td>{{ e.label }}</td>
|
||||
<td>{{ faNum(e.count) }}</td>
|
||||
<td>{{ faNum(e.score) }}</td>
|
||||
<td>{{ faNum(e.coins) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!report.byEvent.length"><td colspan="4" class="muted">در این بازه امتیازی ثبت نشده است.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rep {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rep.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bar {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.kpi {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kpi small {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.kpi b {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.kpi.warn b {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rank {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rank li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.rank .n {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-subtle);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.rank .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank small {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.chg {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chg.up {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.chg.down {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import type { GamificationRewardDto, SaveGamificationRewardRequest } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiField, UiInput, UiModal, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, faNum, REWARD_ICONS } from '@/lib/gamification';
|
||||
|
||||
const COLORS = ['#0d9488', '#f59e0b', '#8b5cf6', '#ef4444', '#3b82f6', '#10b981', '#ec4899'];
|
||||
|
||||
const items = ref<GamificationRewardDto[]>([]);
|
||||
const error = ref('');
|
||||
const modalError = ref('');
|
||||
const open = ref(false);
|
||||
const busy = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
cost: '500',
|
||||
icon: 'gift',
|
||||
color: COLORS[0],
|
||||
stock: '',
|
||||
isActive: true,
|
||||
requiresApproval: true,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = (await api.get<GamificationRewardDto[]>('/admin/gamification/rewards')).data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری پاداشها ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function edit(r: GamificationRewardDto | null) {
|
||||
editingId.value = r?.id ?? null;
|
||||
modalError.value = '';
|
||||
Object.assign(form, {
|
||||
name: r?.name ?? '',
|
||||
description: r?.description ?? '',
|
||||
cost: String(r?.cost ?? 500),
|
||||
icon: r?.icon ?? 'gift',
|
||||
color: r?.color ?? COLORS[0],
|
||||
stock: r?.stock == null ? '' : String(r.stock),
|
||||
isActive: r?.isActive ?? true,
|
||||
requiresApproval: r?.requiresApproval ?? true,
|
||||
});
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const cost = Math.round(Number(form.cost));
|
||||
if (form.name.trim().length < 2 || !(cost > 0)) {
|
||||
modalError.value = 'نام و قیمت (سکه) معتبر وارد کنید.';
|
||||
return;
|
||||
}
|
||||
const body: SaveGamificationRewardRequest = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
cost,
|
||||
icon: form.icon,
|
||||
color: form.color,
|
||||
stock: form.stock.trim() === '' ? null : Math.max(0, Math.round(Number(form.stock))),
|
||||
isActive: form.isActive,
|
||||
requiresApproval: form.requiresApproval,
|
||||
};
|
||||
busy.value = true;
|
||||
try {
|
||||
if (editingId.value) await api.patch(`/admin/gamification/rewards/${editingId.value}`, body);
|
||||
else await api.post('/admin/gamification/rewards', body);
|
||||
open.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
modalError.value = apiError(e, 'ذخیره پاداش ممکن نشد.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: GamificationRewardDto) {
|
||||
if (!window.confirm(`پاداش «${r.name}» حذف شود؟`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/gamification/rewards/${r.id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف پاداش ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rw">
|
||||
<div class="bar">
|
||||
<p class="hint">پاداشها با سکه خریداری میشوند. امتیاز رتبهبندی خرج نمیشود.</p>
|
||||
<UiButton @click="edit(null)"><UiIcon name="plus" :size="15" /> پاداش جدید</UiButton>
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="grid">
|
||||
<article v-for="r in items" :key="r.id" class="card" :class="{ off: !r.isActive }" :style="{ '--c': r.color }">
|
||||
<span class="ic"><UiIcon :name="r.icon as never" :size="22" /></span>
|
||||
<div class="body">
|
||||
<b>{{ r.name }}</b>
|
||||
<small>{{ faNum(r.cost) }} سکه<template v-if="r.stock != null"> · موجودی {{ faNum(r.stock) }}</template></small>
|
||||
<small v-if="r.requiresApproval" class="ap">نیاز به تأیید</small>
|
||||
</div>
|
||||
<span class="acts">
|
||||
<button type="button" title="ویرایش" @click="edit(r)"><UiIcon name="edit" :size="15" /></button>
|
||||
<button type="button" class="del" title="حذف" @click="remove(r)"><UiIcon name="trash" :size="15" /></button>
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<UiModal :open="open" :title="editingId ? 'ویرایش پاداش' : 'پاداش جدید'" @close="open = false">
|
||||
<form class="form" @submit.prevent="save">
|
||||
<UiField label="نام" required><UiInput v-model="form.name" maxlength="120" /></UiField>
|
||||
<div class="row">
|
||||
<UiField label="قیمت (سکه)" required><UiInput v-model="form.cost" type="number" /></UiField>
|
||||
<UiField label="موجودی" hint="خالی = نامحدود"><UiInput v-model="form.stock" type="number" /></UiField>
|
||||
</div>
|
||||
<UiField label="توضیح"><UiTextarea v-model="form.description" :rows="2" /></UiField>
|
||||
<div class="pick">
|
||||
<span>آیکن:</span>
|
||||
<button
|
||||
v-for="ic in REWARD_ICONS"
|
||||
:key="ic"
|
||||
type="button"
|
||||
:class="{ on: form.icon === ic }"
|
||||
@click="form.icon = ic"
|
||||
>
|
||||
<UiIcon :name="ic" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pick">
|
||||
<span>رنگ:</span>
|
||||
<button
|
||||
v-for="c in COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ on: form.color === c }"
|
||||
:style="{ background: c }"
|
||||
:aria-label="c"
|
||||
@click="form.color = c"
|
||||
/>
|
||||
</div>
|
||||
<label class="ck"><input v-model="form.requiresApproval" type="checkbox" /> نیاز به تأیید مدیر</label>
|
||||
<label class="ck"><input v-model="form.isActive" type="checkbox" /> نمایش در فروشگاه</label>
|
||||
<p v-if="modalError" class="err">{{ modalError }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="open = false">انصراف</UiButton>
|
||||
<UiButton :loading="busy" @click="save">ذخیره</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rw {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
--c: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-inline-start: 4px solid var(--c);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body .ap {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.acts button:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.acts .del:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pick button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.pick button.on {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.pick .dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pick .dot.on {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.ck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { GamificationRuleDto, TeamDto } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENTS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiSearch } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum, txIcon } from '@/lib/gamification';
|
||||
import { conditionText } from '../rule-text';
|
||||
import RuleBuilderModal from './RuleBuilderModal.vue';
|
||||
|
||||
const rules = ref<GamificationRuleDto[]>([]);
|
||||
const teams = ref<TeamDto[]>([]);
|
||||
const error = ref('');
|
||||
const q = ref('');
|
||||
const editing = ref<GamificationRuleDto | null>(null);
|
||||
const builderOpen = ref(false);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const s = q.value.trim();
|
||||
if (!s) return rules.value;
|
||||
return rules.value.filter(
|
||||
(r) => r.name.includes(s) || (GAMIFICATION_EVENTS[r.event]?.label ?? '').includes(s),
|
||||
);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [r, t] = await Promise.all([
|
||||
api.get<GamificationRuleDto[]>('/admin/gamification/rules'),
|
||||
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
|
||||
]);
|
||||
rules.value = r.data;
|
||||
teams.value = t.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری قوانین ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
editing.value = null;
|
||||
builderOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(r: GamificationRuleDto) {
|
||||
editing.value = r;
|
||||
builderOpen.value = true;
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
builderOpen.value = false;
|
||||
void load();
|
||||
}
|
||||
|
||||
async function toggle(r: GamificationRuleDto) {
|
||||
try {
|
||||
const { data } = await api.patch<GamificationRuleDto>(`/admin/gamification/rules/${r.id}/toggle`, {
|
||||
isActive: !r.isActive,
|
||||
});
|
||||
Object.assign(r, data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(r: GamificationRuleDto) {
|
||||
try {
|
||||
await api.post(`/admin/gamification/rules/${r.id}/duplicate`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'کپی قانون ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: GamificationRuleDto) {
|
||||
if (!window.confirm(`قانون «${r.name}» حذف شود؟ امتیازهای قبلی باقی میمانند.`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/gamification/rules/${r.id}`);
|
||||
rules.value = rules.value.filter((x) => x.id !== r.id);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف قانون ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function limitsText(r: GamificationRuleDto): string {
|
||||
const out: string[] = [];
|
||||
if (r.maxPerDay) out.push(`${toFaDigits(r.maxPerDay)}/روز`);
|
||||
if (r.maxCoinsPerDay) out.push(`سقف ${faNum(r.maxCoinsPerDay)} سکه/روز`);
|
||||
if (r.maxPerUser) out.push(`${toFaDigits(r.maxPerUser)} بار در کل`);
|
||||
if (r.cooldownMinutes) out.push(`هر ${toFaDigits(r.cooldownMinutes)} دقیقه`);
|
||||
if (r.oncePerEntity) out.push('یک بار/پرونده');
|
||||
if (r.firstTimeOnly) out.push('فقط بار اول');
|
||||
if (r.requiresApproval) out.push('با تأیید مدیر');
|
||||
if (r.roles.length || r.teamIds.length) out.push('مخاطب محدود');
|
||||
return out.join(' · ');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rules">
|
||||
<div class="bar">
|
||||
<UiSearch v-model="q" placeholder="جستجوی قانون یا رویداد…" />
|
||||
<UiButton @click="openNew"><UiIcon name="plus" :size="15" /> قانون جدید</UiButton>
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="r in filtered" :key="r.id" :class="{ off: !r.isActive }">
|
||||
<span class="ic"><UiIcon :name="txIcon({ kind: 'rule', event: r.event })" :size="16" /></span>
|
||||
<button type="button" class="body" @click="openEdit(r)">
|
||||
<b>{{ r.name }}</b>
|
||||
<small>
|
||||
{{ GAMIFICATION_EVENTS[r.event]?.label ?? r.event }}
|
||||
<template v-if="r.conditions.length"> · {{ r.conditions.map(conditionText).join(' و ') }}</template>
|
||||
</small>
|
||||
<small v-if="limitsText(r)" class="lim">{{ limitsText(r) }}</small>
|
||||
</button>
|
||||
<span class="pts">
|
||||
<b :class="{ neg: r.score < 0 }">{{ r.score > 0 ? '+' : '' }}{{ faNum(r.score) }}</b>
|
||||
<em v-if="r.coins">+{{ faNum(r.coins) }} سکه</em>
|
||||
</span>
|
||||
<span class="count" title="تعداد دفعات اعطا">{{ faNum(r.awardedCount) }}×</span>
|
||||
<label class="sw" :title="r.isActive ? 'فعال' : 'غیرفعال'">
|
||||
<input type="checkbox" :checked="r.isActive" @change="toggle(r)" />
|
||||
<i />
|
||||
</label>
|
||||
<span class="acts">
|
||||
<button type="button" title="ویرایش" @click="openEdit(r)"><UiIcon name="edit" :size="15" /></button>
|
||||
<button type="button" title="کپی" @click="duplicate(r)"><UiIcon name="layers" :size="15" /></button>
|
||||
<button type="button" class="del" title="حذف" @click="remove(r)"><UiIcon name="trash" :size="15" /></button>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!filtered.length" class="empty">قانونی پیدا نشد.</li>
|
||||
</ul>
|
||||
|
||||
<RuleBuilderModal
|
||||
:open="builderOpen"
|
||||
:rule="editing"
|
||||
:teams="teams"
|
||||
@close="builderOpen = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rules {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar > :first-child {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list li.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
text-align: start;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.body b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.body small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lim {
|
||||
color: var(--warning-deep) !important;
|
||||
}
|
||||
|
||||
.pts {
|
||||
display: grid;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pts b {
|
||||
color: var(--success-deep);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pts b.neg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pts em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--line);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
inset-inline-start: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sw input:checked + i {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.sw input:checked + i::after {
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.acts button:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.acts .del:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.list li {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-basis: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import type { GamificationSettingsDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiField, UiInput } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError } from '@/lib/gamification';
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>();
|
||||
|
||||
const form = reactive({
|
||||
enabled: true,
|
||||
monthlyGoal: '1500',
|
||||
showFullLeaderboard: true,
|
||||
levels: [] as Array<{ name: string; minScore: string }>,
|
||||
});
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
function fill(s: GamificationSettingsDto) {
|
||||
form.enabled = s.enabled;
|
||||
form.monthlyGoal = String(s.monthlyGoal);
|
||||
form.showFullLeaderboard = s.showFullLeaderboard;
|
||||
form.levels = s.levels.map((l) => ({ name: l.name, minScore: String(l.minScore) }));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
fill((await api.get<GamificationSettingsDto>('/admin/gamification/settings')).data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
|
||||
}
|
||||
});
|
||||
|
||||
function addLevel() {
|
||||
const last = form.levels[form.levels.length - 1];
|
||||
form.levels.push({ name: '', minScore: String((Number(last?.minScore) || 0) + 500) });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
const levels = form.levels
|
||||
.map((l) => ({ name: l.name.trim(), minScore: Math.max(0, Math.round(Number(l.minScore) || 0)) }))
|
||||
.filter((l) => l.name);
|
||||
if (!levels.length) {
|
||||
error.value = 'حداقل یک سطح لازم است.';
|
||||
return;
|
||||
}
|
||||
levels.sort((a, b) => a.minScore - b.minScore);
|
||||
levels[0].minScore = 0;
|
||||
const goal = Math.round(Number(form.monthlyGoal));
|
||||
if (!(goal >= 1)) {
|
||||
error.value = 'هدف ماهانه باید عدد مثبت باشد.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.patch<GamificationSettingsDto>('/admin/gamification/settings', {
|
||||
enabled: form.enabled,
|
||||
monthlyGoal: goal,
|
||||
showFullLeaderboard: form.showFullLeaderboard,
|
||||
levels,
|
||||
});
|
||||
fill(data);
|
||||
ok.value = 'تنظیمات ذخیره شد.';
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="st" @submit.prevent="save">
|
||||
<article class="card">
|
||||
<h3><UiIcon name="sliders" :size="17" /> تنظیمات کلی</h3>
|
||||
<label class="ck">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>
|
||||
<b>امتیازدهی فعال باشد</b>
|
||||
<small>اگر خاموش شود، رویدادهای جدید امتیاز نمیگیرند ولی سوابق حفظ میشود.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input v-model="form.showFullLeaderboard" type="checkbox" />
|
||||
<span>
|
||||
<b>جدول کامل برای همه نمایش داده شود</b>
|
||||
<small>اگر خاموش باشد، همکاران فقط ۱۰ نفر اول و رتبه خودشان را میبینند.</small>
|
||||
</span>
|
||||
</label>
|
||||
<UiField label="هدف ماهانه امتیاز" hint="در کارت «هدف بعدی» نمایش داده میشود" class="goal">
|
||||
<UiInput v-model="form.monthlyGoal" type="number" />
|
||||
</UiField>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3><UiIcon name="trend" :size="17" /> سطحها</h3>
|
||||
<p class="hint">سطح بر اساس امتیاز کل محاسبه میشود. سطح اول همیشه از صفر شروع میشود.</p>
|
||||
<div v-for="(l, i) in form.levels" :key="i" class="lvl">
|
||||
<span class="n">{{ toFaDigits(i + 1) }}</span>
|
||||
<UiInput v-model="l.name" placeholder="نام سطح" maxlength="40" />
|
||||
<UiInput v-model="l.minScore" type="number" :disabled="i === 0" placeholder="حداقل امتیاز" />
|
||||
<button type="button" class="x" :disabled="form.levels.length <= 1" aria-label="حذف سطح" @click="form.levels.splice(i, 1)">
|
||||
<UiIcon name="x" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<UiButton v-if="form.levels.length < 20" type="button" variant="dashed" size="sm" @click="addLevel">+ افزودن سطح</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div><UiButton type="submit" :loading="busy">ذخیره تنظیمات</UiButton></div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.st {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: var(--sp-4);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.ck {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ck input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ck span {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ck b {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.ck small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.goal {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.lvl {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 2fr) minmax(0, 1fr) 28px;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.n {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-weight: 800;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.x {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.x:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.x:not(:disabled):hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,534 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type {
|
||||
GamificationCondition,
|
||||
GamificationConditionOp,
|
||||
GamificationEvent,
|
||||
GamificationRuleDto,
|
||||
SaveGamificationRuleRequest,
|
||||
TeamDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
GAMIFICATION_CONDITION_OPS,
|
||||
GAMIFICATION_EVENT_GROUP_LABELS,
|
||||
GAMIFICATION_EVENT_KEYS,
|
||||
GAMIFICATION_EVENTS,
|
||||
GAMIFICATION_FIELDS,
|
||||
} from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, ROLE_LABELS } from '@/lib/gamification';
|
||||
import { conditionText } from '../rule-text';
|
||||
|
||||
const props = defineProps<{ open: boolean; rule: GamificationRuleDto | null; teams: TeamDto[] }>();
|
||||
const emit = defineEmits<{ close: []; saved: [GamificationRuleDto] }>();
|
||||
|
||||
const NUMBER_OPS: GamificationConditionOp[] = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in'];
|
||||
const STRING_OPS: GamificationConditionOp[] = ['eq', 'neq', 'contains', 'in'];
|
||||
const BOOL_OPS: GamificationConditionOp[] = ['is_true', 'is_false'];
|
||||
|
||||
interface Form {
|
||||
name: string;
|
||||
description: string;
|
||||
event: string;
|
||||
score: string;
|
||||
coins: string;
|
||||
conditions: Array<{ field: string; op: string; value: string }>;
|
||||
isActive: boolean;
|
||||
maxPerDay: string;
|
||||
maxCoinsPerDay: string;
|
||||
maxPerUser: string;
|
||||
cooldownMinutes: string;
|
||||
oncePerEntity: boolean;
|
||||
firstTimeOnly: boolean;
|
||||
requiresApproval: boolean;
|
||||
roles: string[];
|
||||
teamIds: string[];
|
||||
}
|
||||
|
||||
function blank(): Form {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
event: 'DEAL_WON',
|
||||
score: '10',
|
||||
coins: '0',
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
maxPerDay: '',
|
||||
maxCoinsPerDay: '',
|
||||
maxPerUser: '',
|
||||
cooldownMinutes: '',
|
||||
oncePerEntity: false,
|
||||
firstTimeOnly: false,
|
||||
requiresApproval: false,
|
||||
roles: [],
|
||||
teamIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive<Form>(blank());
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
const str = (v: number | null | undefined) => (v == null ? '' : String(v));
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
const r = props.rule;
|
||||
Object.assign(
|
||||
form,
|
||||
r
|
||||
? {
|
||||
name: r.name,
|
||||
description: r.description ?? '',
|
||||
event: r.event,
|
||||
score: String(r.score),
|
||||
coins: String(r.coins),
|
||||
conditions: r.conditions.map((c) => ({ field: c.field, op: c.op, value: str(c.value as number) })),
|
||||
isActive: r.isActive,
|
||||
maxPerDay: str(r.maxPerDay),
|
||||
maxCoinsPerDay: str(r.maxCoinsPerDay),
|
||||
maxPerUser: str(r.maxPerUser),
|
||||
cooldownMinutes: str(r.cooldownMinutes),
|
||||
oncePerEntity: r.oncePerEntity,
|
||||
firstTimeOnly: r.firstTimeOnly,
|
||||
requiresApproval: r.requiresApproval,
|
||||
roles: [...r.roles],
|
||||
teamIds: [...r.teamIds],
|
||||
}
|
||||
: blank(),
|
||||
);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const eventGroups = computed(() => {
|
||||
const map = new Map<string, GamificationEvent[]>();
|
||||
for (const key of GAMIFICATION_EVENT_KEYS) {
|
||||
const g = GAMIFICATION_EVENTS[key].group;
|
||||
map.set(g, [...(map.get(g) ?? []), key]);
|
||||
}
|
||||
return [...map.entries()].map(([g, keys]) => ({
|
||||
label: GAMIFICATION_EVENT_GROUP_LABELS[g as keyof typeof GAMIFICATION_EVENT_GROUP_LABELS],
|
||||
keys,
|
||||
}));
|
||||
});
|
||||
|
||||
const eventKey = computed(() => form.event as GamificationEvent);
|
||||
const fields = computed(() => GAMIFICATION_EVENTS[eventKey.value]?.fields ?? []);
|
||||
const isBool = (op: string) => BOOL_OPS.includes(op as GamificationConditionOp);
|
||||
|
||||
watch(
|
||||
() => form.event,
|
||||
() => {
|
||||
form.conditions = form.conditions.filter((c) => fields.value.includes(c.field));
|
||||
},
|
||||
);
|
||||
|
||||
function opsFor(field: string): GamificationConditionOp[] {
|
||||
const t = GAMIFICATION_FIELDS[field]?.type;
|
||||
return t === 'boolean' ? BOOL_OPS : t === 'number' ? NUMBER_OPS : STRING_OPS;
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
const field = fields.value[0];
|
||||
if (!field) return;
|
||||
form.conditions.push({ field, op: opsFor(field)[0], value: '' });
|
||||
}
|
||||
|
||||
function onFieldChange(i: number) {
|
||||
const c = form.conditions[i];
|
||||
if (!opsFor(c.field).includes(c.op as GamificationConditionOp)) c.op = opsFor(c.field)[0];
|
||||
c.value = '';
|
||||
}
|
||||
|
||||
function toggle(list: string[], value: string) {
|
||||
const i = list.indexOf(value);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
else list.push(value);
|
||||
}
|
||||
|
||||
const optInt = (v: string) => (v.trim() === '' ? null : Math.max(0, Math.round(Number(v))));
|
||||
|
||||
function buildConditions(): GamificationCondition[] {
|
||||
return form.conditions.map((c) => {
|
||||
const op = c.op as GamificationConditionOp;
|
||||
if (isBool(op)) return { field: c.field, op, value: null };
|
||||
const isNum = GAMIFICATION_FIELDS[c.field]?.type === 'number' && op !== 'in';
|
||||
return { field: c.field, op, value: isNum ? Number(c.value) : c.value.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
const preview = computed(() => {
|
||||
const conds = buildConditions();
|
||||
const parts = [`وقتی «${GAMIFICATION_EVENTS[eventKey.value]?.label ?? form.event}»`];
|
||||
if (conds.length) parts.push(`و ${conds.map(conditionText).join(' و ')}`);
|
||||
const reward = [
|
||||
Number(form.score) ? `${form.score} امتیاز` : '',
|
||||
Number(form.coins) ? `${form.coins} سکه` : '',
|
||||
].filter(Boolean);
|
||||
parts.push(`← ${reward.join(' + ') || 'بدون پاداش'}`);
|
||||
return parts.join(' ');
|
||||
});
|
||||
|
||||
async function save() {
|
||||
error.value = '';
|
||||
if (form.name.trim().length < 2) {
|
||||
error.value = 'نام قانون را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
const bad = form.conditions.find(
|
||||
(c) => !isBool(c.op) && (c.value.trim() === '' || (GAMIFICATION_FIELDS[c.field]?.type === 'number' && c.op !== 'in' && !Number.isFinite(Number(c.value)))),
|
||||
);
|
||||
if (bad) {
|
||||
error.value = `مقدار شرط «${GAMIFICATION_FIELDS[bad.field]?.label ?? bad.field}» معتبر نیست.`;
|
||||
return;
|
||||
}
|
||||
const body: SaveGamificationRuleRequest = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
event: eventKey.value,
|
||||
score: Math.round(Number(form.score) || 0),
|
||||
coins: Math.max(0, Math.round(Number(form.coins) || 0)),
|
||||
conditions: buildConditions(),
|
||||
isActive: form.isActive,
|
||||
maxPerDay: optInt(form.maxPerDay),
|
||||
maxCoinsPerDay: optInt(form.maxCoinsPerDay),
|
||||
maxPerUser: optInt(form.maxPerUser),
|
||||
cooldownMinutes: optInt(form.cooldownMinutes),
|
||||
oncePerEntity: form.oncePerEntity,
|
||||
firstTimeOnly: form.firstTimeOnly,
|
||||
requiresApproval: form.requiresApproval,
|
||||
roles: form.roles,
|
||||
teamIds: form.teamIds,
|
||||
};
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = props.rule
|
||||
? await api.patch<GamificationRuleDto>(`/admin/gamification/rules/${props.rule.id}`, body)
|
||||
: await api.post<GamificationRuleDto>('/admin/gamification/rules', body);
|
||||
emit('saved', data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره قانون ممکن نشد.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="open" :title="rule ? 'ویرایش قانون امتیاز' : 'قانون امتیاز جدید'" @close="emit('close')">
|
||||
<form class="builder" @submit.prevent="save">
|
||||
<div class="row2">
|
||||
<UiField label="نام قانون" required>
|
||||
<UiInput v-model="form.name" placeholder="مثلاً: معامله بزرگ برنده شد" maxlength="120" />
|
||||
</UiField>
|
||||
<label class="switch">
|
||||
<input v-model="form.isActive" type="checkbox" />
|
||||
<span>فعال</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="step">
|
||||
<h4><span class="tag when">WHEN</span> وقتی این اتفاق بیفتد</h4>
|
||||
<UiSelect v-model="form.event">
|
||||
<optgroup v-for="g in eventGroups" :key="g.label" :label="g.label">
|
||||
<option v-for="k in g.keys" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
|
||||
</optgroup>
|
||||
</UiSelect>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h4><span class="tag if">IF</span> و این شرطها برقرار باشد <small>(اختیاری، همه با هم)</small></h4>
|
||||
<div v-for="(c, i) in form.conditions" :key="i" class="cond">
|
||||
<UiSelect v-model="c.field" size="sm" @update:model-value="onFieldChange(i)">
|
||||
<option v-for="f in fields" :key="f" :value="f">{{ GAMIFICATION_FIELDS[f]?.label ?? f }}</option>
|
||||
</UiSelect>
|
||||
<UiSelect v-model="c.op" size="sm">
|
||||
<option v-for="op in opsFor(c.field)" :key="op" :value="op">{{ GAMIFICATION_CONDITION_OPS[op] }}</option>
|
||||
</UiSelect>
|
||||
<template v-if="!isBool(c.op)">
|
||||
<UiSelect v-if="GAMIFICATION_FIELDS[c.field]?.options && c.op !== 'in'" v-model="c.value" size="sm">
|
||||
<option value="">انتخاب…</option>
|
||||
<option v-for="o in GAMIFICATION_FIELDS[c.field].options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</UiSelect>
|
||||
<UiInput
|
||||
v-else
|
||||
v-model="c.value"
|
||||
size="sm"
|
||||
:type="GAMIFICATION_FIELDS[c.field]?.type === 'number' && c.op !== 'in' ? 'number' : 'text'"
|
||||
placeholder="مقدار"
|
||||
/>
|
||||
</template>
|
||||
<span v-else />
|
||||
<button type="button" class="x" aria-label="حذف شرط" @click="form.conditions.splice(i, 1)">
|
||||
<UiIcon name="x" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<UiButton v-if="form.conditions.length < 10" type="button" variant="dashed" size="sm" @click="addCondition">
|
||||
+ افزودن شرط
|
||||
</UiButton>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h4><span class="tag then">THEN</span> این پاداش را بده</h4>
|
||||
<div class="row2">
|
||||
<UiField label="امتیاز (رتبهبندی)" hint="منفی هم مجاز است">
|
||||
<UiInput v-model="form.score" type="number" />
|
||||
</UiField>
|
||||
<UiField label="سکه (فروشگاه)">
|
||||
<UiInput v-model="form.coins" type="number" />
|
||||
</UiField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="step adv" :open="!!(form.maxPerDay || form.maxPerUser || form.cooldownMinutes || form.roles.length || form.teamIds.length)">
|
||||
<summary>محدودیتها و مخاطبان</summary>
|
||||
<div class="row4">
|
||||
<UiField label="حداکثر بار در روز"><UiInput v-model="form.maxPerDay" type="number" placeholder="نامحدود" /></UiField>
|
||||
<UiField label="سقف سکه روزانه"><UiInput v-model="form.maxCoinsPerDay" type="number" placeholder="نامحدود" /></UiField>
|
||||
<UiField label="حداکثر بار برای هر نفر"><UiInput v-model="form.maxPerUser" type="number" placeholder="نامحدود" /></UiField>
|
||||
<UiField label="فاصله زمانی (دقیقه)"><UiInput v-model="form.cooldownMinutes" type="number" placeholder="ندارد" /></UiField>
|
||||
</div>
|
||||
<div class="checks">
|
||||
<label><input v-model="form.oncePerEntity" type="checkbox" /> یک بار برای هر پرونده</label>
|
||||
<label><input v-model="form.firstTimeOnly" type="checkbox" /> فقط اولین بار</label>
|
||||
<label><input v-model="form.requiresApproval" type="checkbox" /> نیاز به تأیید مدیر</label>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chips-label">نقشها:</span>
|
||||
<button
|
||||
v-for="(label, key) in ROLE_LABELS"
|
||||
:key="key"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: form.roles.includes(key) }"
|
||||
@click="toggle(form.roles, key)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
<small>{{ form.roles.length ? '' : 'همه نقشها' }}</small>
|
||||
</div>
|
||||
<div v-if="teams.length" class="chips">
|
||||
<span class="chips-label">تیمها:</span>
|
||||
<button
|
||||
v-for="t in teams"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: form.teamIds.includes(t.id) }"
|
||||
@click="toggle(form.teamIds, t.id)"
|
||||
>
|
||||
{{ t.name }}
|
||||
</button>
|
||||
<small>{{ form.teamIds.length ? '' : 'همه تیمها' }}</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<UiField label="توضیح (اختیاری)">
|
||||
<UiTextarea v-model="form.description" :rows="2" />
|
||||
</UiField>
|
||||
|
||||
<p class="preview"><UiIcon name="sparkle" :size="14" /> {{ preview }}</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
|
||||
<UiButton :loading="busy" @click="save">ذخیره قانون</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.builder {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.step .row2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.row4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 8px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.step h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.step h4 small {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.tag.when {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.tag.if {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.tag.then {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.cond {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.2fr) 28px;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.x {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.x:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.adv summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.checks label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chips-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chips small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
font-size: var(--fs-xs);
|
||||
cursor: pointer;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cond {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px;
|
||||
}
|
||||
|
||||
.cond > :nth-child(3) {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { inject, type InjectionKey, type Ref } from 'vue';
|
||||
import type { GamificationMeDto } from '@mento/shared';
|
||||
|
||||
export type ClubContext = {
|
||||
me: Ref<GamificationMeDto | null>;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const CLUB_CTX: InjectionKey<ClubContext> = Symbol('club');
|
||||
|
||||
export function useClub(): ClubContext {
|
||||
const ctx = inject(CLUB_CTX);
|
||||
if (!ctx) throw new Error('ClubContext not provided');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GamificationCondition } from '@mento/shared';
|
||||
import { GAMIFICATION_CONDITION_OPS, GAMIFICATION_FIELDS } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
/** متن خوانا برای یک شرط، مثل «مبلغ بیشتر از ۱۰۰٬۰۰۰» */
|
||||
export function conditionText(c: GamificationCondition): string {
|
||||
const field = GAMIFICATION_FIELDS[c.field];
|
||||
const label = field?.label ?? c.field;
|
||||
if (c.op === 'is_true') return `${label}: بله`;
|
||||
if (c.op === 'is_false') return `${label}: خیر`;
|
||||
const raw = c.value ?? '';
|
||||
const option = field?.options?.find((o) => o.value === String(raw));
|
||||
const value = option ? option.label : typeof raw === 'number' ? toFaDigits(raw.toLocaleString('en-US')) : String(raw);
|
||||
return `${label} ${GAMIFICATION_CONDITION_OPS[c.op]} ${value}`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import QuickCreate from '@/components/QuickCreate.vue';
|
||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
ActivitySummaryDto,
|
||||
@@ -49,9 +50,8 @@ function pathIn(prefixes: string[]) {
|
||||
const dashboardActive = computed(() => route.path === '/');
|
||||
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||
const reportsActive = computed(() => pathIn(['/reports']));
|
||||
const leadsActive = computed(() =>
|
||||
pathIn(['/leads', '/settings/lead-stages', '/settings/lead-forms']),
|
||||
);
|
||||
const clubActive = computed(() => pathIn(['/club']));
|
||||
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
|
||||
const customersActive = computed(
|
||||
() => pathIn(['/contacts']) || (isMobileNav.value && leadsActive.value),
|
||||
);
|
||||
@@ -85,6 +85,7 @@ const toolsActive = computed(() =>
|
||||
'/cheques',
|
||||
'/attachments',
|
||||
'/reservations',
|
||||
'/settings/lead-forms',
|
||||
]),
|
||||
);
|
||||
const settingsActive = computed(() =>
|
||||
@@ -104,6 +105,7 @@ const settingsActive = computed(() =>
|
||||
const moreSectionActive = computed(
|
||||
() =>
|
||||
routinesActive.value ||
|
||||
clubActive.value ||
|
||||
notesActive.value ||
|
||||
conversationsActive.value ||
|
||||
supportActive.value ||
|
||||
@@ -539,6 +541,18 @@ onUnmounted(() => {
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('gamification')"
|
||||
to="/club"
|
||||
class="nav-item"
|
||||
title="باشگاه امتیازات"
|
||||
:class="{ 'router-link-active': clubActive }"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<UiIcon name="trophy" :size="collapsed ? 20 : 18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('conversations')"
|
||||
to="/conversations"
|
||||
@@ -796,6 +810,7 @@ onUnmounted(() => {
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
<GamificationToaster v-if="moduleAllowed('gamification')" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -899,6 +914,16 @@ onUnmounted(() => {
|
||||
<span>کارهای روتین</span>
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('gamification')"
|
||||
to="/club"
|
||||
class="nav-item"
|
||||
:class="{ 'router-link-active': clubActive }"
|
||||
@click="closeHub(); closeMore()"
|
||||
>
|
||||
<UiIcon name="trophy" :size="18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>یادداشتها</span>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GamificationEvent, GamificationTxDto } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENTS } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
export { apiError } from '@/lib/routines';
|
||||
|
||||
type IconName =
|
||||
| 'trophy'
|
||||
| 'user-plus'
|
||||
| 'funnel'
|
||||
| 'briefcase'
|
||||
| 'phone'
|
||||
| 'calendar'
|
||||
| 'document'
|
||||
| 'check'
|
||||
| 'refresh'
|
||||
| 'dollar'
|
||||
| 'chat'
|
||||
| 'star'
|
||||
| 'medal'
|
||||
| 'gift'
|
||||
| 'sliders'
|
||||
| 'building'
|
||||
| 'trend';
|
||||
|
||||
const EVENT_ICONS: Record<GamificationEvent, IconName> = {
|
||||
LEAD_CREATED: 'funnel',
|
||||
LEAD_STAGE_CHANGED: 'trend',
|
||||
LEAD_CONVERTED: 'user-plus',
|
||||
CUSTOMER_CREATED: 'user-plus',
|
||||
COMPANY_CREATED: 'building',
|
||||
DEAL_CREATED: 'briefcase',
|
||||
DEAL_STAGE_CHANGED: 'trend',
|
||||
DEAL_WON: 'trophy',
|
||||
DEAL_LOST: 'briefcase',
|
||||
CALL_LOGGED: 'phone',
|
||||
MEETING_CREATED: 'calendar',
|
||||
FOLLOW_UP_CREATED: 'calendar',
|
||||
NOTE_CREATED: 'document',
|
||||
ACTIVITY_CREATED: 'check',
|
||||
TASK_COMPLETED: 'check',
|
||||
ROUTINE_COMPLETED: 'refresh',
|
||||
QUOTE_CREATED: 'document',
|
||||
INVOICE_ISSUED: 'dollar',
|
||||
PAYMENT_REGISTERED: 'dollar',
|
||||
TICKET_CREATED: 'chat',
|
||||
TICKET_RESOLVED: 'chat',
|
||||
};
|
||||
|
||||
export function txIcon(tx: Pick<GamificationTxDto, 'kind' | 'event'>): IconName {
|
||||
if (tx.kind === 'bonus') return 'star';
|
||||
if (tx.kind === 'achievement') return 'medal';
|
||||
if (tx.kind === 'redeem' || tx.kind === 'refund') return 'gift';
|
||||
if (tx.kind === 'adjust') return 'sliders';
|
||||
return tx.event ? EVENT_ICONS[tx.event] : 'star';
|
||||
}
|
||||
|
||||
export function eventLabel(event: GamificationEvent | null | undefined): string {
|
||||
return event ? GAMIFICATION_EVENTS[event]?.label ?? event : '';
|
||||
}
|
||||
|
||||
/** عدد فارسی با جداکننده هزارگان */
|
||||
export function faNum(value: number | null | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return toFaDigits(Math.round(value).toLocaleString('en-US'));
|
||||
}
|
||||
|
||||
export function signedFa(value: number): string {
|
||||
if (!value) return toFaDigits(0);
|
||||
return `${value > 0 ? '+' : '−'}${faNum(Math.abs(value))}`;
|
||||
}
|
||||
|
||||
/** «۵ دقیقه پیش»، «دیروز ۱۴:۲۰» … */
|
||||
export function timeAgo(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const min = Math.floor(diff / 60_000);
|
||||
if (min < 1) return 'همین الان';
|
||||
if (min < 60) return `${toFaDigits(min)} دقیقه پیش`;
|
||||
const hours = Math.floor(min / 60);
|
||||
if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${toFaDigits(days)} روز پیش`;
|
||||
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium' }).format(d);
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export const REWARD_ICONS = ['gift', 'star', 'trophy', 'medal', 'crown', 'calendar-sun', 'bag', 'target', 'fire'] as const;
|
||||
export const BADGE_ICONS = ['medal', 'trophy', 'crown', 'star', 'fire', 'phone', 'users', 'funnel', 'target', 'check'] as const;
|
||||
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'مالک',
|
||||
admin: 'مدیر',
|
||||
sales: 'فروش',
|
||||
support: 'پشتیبانی',
|
||||
};
|
||||
@@ -136,15 +136,6 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
admin: true,
|
||||
module: 'leads',
|
||||
},
|
||||
{
|
||||
to: '/settings/lead-forms',
|
||||
title: 'فرمساز سرنخ',
|
||||
desc: 'لینک عمومی، iframe و QR برای جذب سرنخ',
|
||||
icon: 'document',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'lead_forms',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -367,6 +358,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'amber',
|
||||
module: 'attachments',
|
||||
},
|
||||
{
|
||||
to: '/settings/lead-forms',
|
||||
title: 'فرمساز سرنخ',
|
||||
desc: 'لینک عمومی، iframe و QR برای جذب سرنخ',
|
||||
icon: 'document',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'lead_forms',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -278,6 +278,16 @@ export const router = createRouter({
|
||||
name: 'loyalty',
|
||||
component: () => import('@/views/LoyaltyClubView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'club',
|
||||
name: 'gamification',
|
||||
component: () => import('@/views/gamification/GamificationView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'club/admin',
|
||||
name: 'gamification-admin',
|
||||
component: () => import('@/views/gamification/GamificationAdminView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'deals/new',
|
||||
name: 'deal-new',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationReportDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import AdminRules from '@/components/gamification/admin/AdminRules.vue';
|
||||
import AdminApprovals from '@/components/gamification/admin/AdminApprovals.vue';
|
||||
import AdminRewards from '@/components/gamification/admin/AdminRewards.vue';
|
||||
import AdminAchievements from '@/components/gamification/admin/AdminAchievements.vue';
|
||||
import AdminAdjust from '@/components/gamification/admin/AdminAdjust.vue';
|
||||
import AdminSettings from '@/components/gamification/admin/AdminSettings.vue';
|
||||
import AdminReports from '@/components/gamification/admin/AdminReports.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type Tab = 'rules' | 'redemptions' | 'rewards' | 'badges' | 'adjust' | 'settings' | 'reports';
|
||||
const TABS: Array<{
|
||||
value: Tab;
|
||||
label: string;
|
||||
icon: 'sliders' | 'check' | 'gift' | 'medal' | 'edit' | 'settings' | 'chart';
|
||||
}> = [
|
||||
{ value: 'rules', label: 'قوانین امتیاز', icon: 'sliders' },
|
||||
{ value: 'redemptions', label: 'تأییدها', icon: 'check' },
|
||||
{ value: 'rewards', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها و بونوسها', icon: 'medal' },
|
||||
{ value: 'adjust', label: 'تنظیم دستی', icon: 'edit' },
|
||||
{ value: 'reports', label: 'گزارش و ROI', icon: 'chart' },
|
||||
{ value: 'settings', label: 'تنظیمات', icon: 'settings' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'owner' || auth.user?.role === 'admin');
|
||||
const pendingCount = ref(0);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'rules';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'rules' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const { data } = await api.get<GamificationReportDto>('/admin/gamification/reports', {
|
||||
params: { period: 'today' },
|
||||
});
|
||||
pendingCount.value = data.totals.pendingApprovals + data.totals.pendingRedemptions;
|
||||
} catch {
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onChanged() {
|
||||
void loadPending();
|
||||
window.dispatchEvent(new CustomEvent('gamification:updated'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) void loadPending();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page gadmin">
|
||||
<header class="head">
|
||||
<RouterLink to="/club" class="back"><UiIcon name="chevron-right" :size="16" /> باشگاه امتیازات</RouterLink>
|
||||
<h1>مدیریت باشگاه امتیازات</h1>
|
||||
<p>قوانین امتیازدهی، پاداشها، نشانها و درخواستها را از اینجا مدیریت کنید.</p>
|
||||
</header>
|
||||
|
||||
<p v-if="!isAdmin" class="denied">این بخش فقط برای مالک و مدیران سازمان در دسترس است.</p>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs" aria-label="بخشهای مدیریت باشگاه">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
<em v-if="t.value === 'redemptions' && pendingCount">{{ toFaDigits(pendingCount) }}</em>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AdminRules v-if="tab === 'rules'" />
|
||||
<AdminApprovals v-else-if="tab === 'redemptions'" @changed="onChanged" />
|
||||
<AdminRewards v-else-if="tab === 'rewards'" />
|
||||
<AdminAchievements v-else-if="tab === 'badges'" />
|
||||
<AdminAdjust v-else-if="tab === 'adjust'" @changed="onChanged" />
|
||||
<AdminReports v-else-if="tab === 'reports'" />
|
||||
<AdminSettings v-else-if="tab === 'settings'" @changed="onChanged" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gadmin {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.denied {
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: var(--accent-deep);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab em {
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,653 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationLeaderboardDto, GamificationMeDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import ClubHome from '@/components/gamification/ClubHome.vue';
|
||||
import ClubHistory from '@/components/gamification/ClubHistory.vue';
|
||||
import ClubStore from '@/components/gamification/ClubStore.vue';
|
||||
import ClubBadges from '@/components/gamification/ClubBadges.vue';
|
||||
import ClubTeams from '@/components/gamification/ClubTeams.vue';
|
||||
import ClubRulesModal from '@/components/gamification/ClubRulesModal.vue';
|
||||
import { CLUB_CTX } from '@/components/gamification/club-context';
|
||||
|
||||
type Tab = 'home' | 'history' | 'store' | 'badges' | 'teams';
|
||||
const TABS: Array<{ value: Tab; label: string; icon: 'home' | 'clock' | 'gift' | 'medal' | 'users' }> = [
|
||||
{ value: 'home', label: 'باشگاه', icon: 'home' },
|
||||
{ value: 'history', label: 'تاریخچه امتیازات', icon: 'clock' },
|
||||
{ value: 'store', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها', icon: 'medal' },
|
||||
{ value: 'teams', label: 'رقابت تیمی', icon: 'users' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const me = ref<GamificationMeDto | null>(null);
|
||||
const podium = ref<GamificationLeaderboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
const rulesOpen = ref(false);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'home';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'home' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [m, p] = await Promise.all([
|
||||
api.get<GamificationMeDto>('/gamification/me', { params: { period: 'month' } }),
|
||||
api.get<GamificationLeaderboardDto>('/gamification/leaderboard', { params: { period: 'month', limit: 3 } }),
|
||||
]);
|
||||
me.value = m.data;
|
||||
podium.value = p.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری باشگاه امتیازات ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
provide(CLUB_CTX, { me, reload: load });
|
||||
|
||||
/** سکوی سه نفر اول به ترتیب نمایش: دوم، اول، سوم */
|
||||
const podiumOrder = computed(() => {
|
||||
const items = podium.value?.items ?? [];
|
||||
return [items[1], items[0], items[2]].filter((x): x is NonNullable<typeof x> => !!x && x.score > 0);
|
||||
});
|
||||
|
||||
const gapText = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m || m.rank == null) return '';
|
||||
if (m.rank === 1 && m.periodScore > 0) return 'تو صدرنشین این ماهی! همینطور ادامه بده.';
|
||||
if (m.gapToFirst != null && m.gapToFirst > 0) {
|
||||
return `تا رتبه اول فقط ${faNum(m.gapToFirst)} امتیاز فاصله داری!`;
|
||||
}
|
||||
return 'با اولین امتیاز این ماه وارد رقابت شو!';
|
||||
});
|
||||
|
||||
const gapProgress = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m) return 0;
|
||||
if (m.rank === 1) return 100;
|
||||
const top = m.periodScore + (m.gapToFirst ?? 0);
|
||||
return top > 0 ? Math.round((m.periodScore / top) * 100) : 0;
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}, 60_000);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.rules,
|
||||
(v) => {
|
||||
if (v === '1') rulesOpen.value = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page club">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div class="hero-title">
|
||||
<span class="star"><UiIcon name="star" :size="20" /></span>
|
||||
<div>
|
||||
<h1>باشگاه امتیازات</h1>
|
||||
<p>هر کار خوب، امتیاز و سکه دارد؛ رقابت کن، پیشرفت کن و پاداش بگیر.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="hero-btn" @click="rulesOpen = true">
|
||||
<UiIcon name="lightbulb" :size="16" />
|
||||
<span>قوانین و نحوه امتیازدهی</span>
|
||||
</button>
|
||||
<button v-if="me?.isManager" type="button" class="hero-btn solid" @click="router.push('/club/admin')">
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
<span>مدیریت باشگاه</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="hero-error">{{ error }}</p>
|
||||
|
||||
<div v-else class="hero-body" :class="{ loading }">
|
||||
<div class="podium" aria-label="سه نفر برتر این ماه">
|
||||
<div
|
||||
v-for="p in podiumOrder"
|
||||
:key="p.userId"
|
||||
class="pod"
|
||||
:class="[`r${p.rank}`, { me: p.isMe }]"
|
||||
>
|
||||
<span v-if="p.rank === 1" class="crown"><UiIcon name="crown" :size="18" /></span>
|
||||
<UserAvatar :user-id="p.userId" :has-avatar="p.hasAvatar" :name="p.fullName" :size="p.rank === 1 ? 54 : 44" />
|
||||
<strong class="pod-name">{{ p.isMe ? 'شما' : p.fullName }}</strong>
|
||||
<span class="pod-score">{{ faNum(p.score) }}</span>
|
||||
<span class="pod-step">{{ toFaDigits(p.rank) }}</span>
|
||||
</div>
|
||||
<p v-if="!podiumOrder.length && !loading" class="pod-empty">
|
||||
هنوز امتیازی در این ماه ثبت نشده — اولین نفر باش!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<span class="stat-label">رتبه شما</span>
|
||||
<strong>{{ me?.rank ? `#${toFaDigits(me.rank)}` : '—' }}</strong>
|
||||
<small v-if="me">از {{ toFaDigits(me.totalUsers) }} نفر</small>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">امتیاز کل</span>
|
||||
<strong>{{ faNum(me?.score ?? 0) }}</strong>
|
||||
<small v-if="me">این ماه {{ faNum(me.periodScore) }}</small>
|
||||
</div>
|
||||
<div class="stat coin">
|
||||
<span class="stat-label">موجودی سکه</span>
|
||||
<strong>{{ faNum(me?.coins ?? 0) }}</strong>
|
||||
<small>قابل خرج در فروشگاه</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap">
|
||||
<div class="gap-text">
|
||||
<UiIcon name="target" :size="15" />
|
||||
<span>{{ gapText }}</span>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: `${gapProgress}%` }" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-side">
|
||||
<div class="goal">
|
||||
<div class="goal-head">
|
||||
<span>هدف بعدی</span>
|
||||
<strong>{{ toFaDigits(me?.goal.progress ?? 0) }}٪</strong>
|
||||
</div>
|
||||
<p>رسیدن به {{ faNum(me?.goal.target ?? 0) }} امتیاز در این ماه</p>
|
||||
<div class="bar warm"><i :style="{ width: `${me?.goal.progress ?? 0}%` }" /></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="me?.nextReward"
|
||||
type="button"
|
||||
class="next-reward"
|
||||
@click="setTab('store')"
|
||||
>
|
||||
<UiIcon name="gift" :size="18" />
|
||||
<span>
|
||||
<small>پاداش بعدی</small>
|
||||
<b>{{ me.nextReward.name }}</b>
|
||||
</span>
|
||||
<em>{{ faNum(me.nextReward.remaining) }} سکه مانده</em>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="c-tabs" aria-label="بخشهای باشگاه امتیازات">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="c-tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ClubHome v-if="tab === 'home'" @go="setTab" />
|
||||
<ClubHistory v-else-if="tab === 'history'" />
|
||||
<ClubStore v-else-if="tab === 'store'" />
|
||||
<ClubBadges v-else-if="tab === 'badges'" />
|
||||
<ClubTeams v-else-if="tab === 'teams'" />
|
||||
|
||||
<ClubRulesModal :open="rulesOpen" @close="rulesOpen = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.club {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
padding: var(--sp-5);
|
||||
color: #ecfeff;
|
||||
background:
|
||||
radial-gradient(circle at 12% 20%, rgba(45, 212, 191, 0.28), transparent 42%),
|
||||
radial-gradient(circle at 85% 90%, rgba(250, 204, 21, 0.14), transparent 40%),
|
||||
linear-gradient(135deg, #0b3b3c 0%, #0f5154 55%, #115e59 100%);
|
||||
box-shadow: 0 18px 40px -24px rgba(15, 81, 84, 0.8);
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 6px 16px -6px rgba(245, 158, 11, 0.8);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.95rem;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hero-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.hero-btn.solid {
|
||||
background: #fff;
|
||||
color: #0f5154;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.hero-error {
|
||||
margin: 0;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||
gap: var(--sp-4);
|
||||
align-items: stretch;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-body.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.pod {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 92px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pod-name {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fff;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pod-score {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fde68a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pod-step {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px 10px 4px 4px;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: #0f5154;
|
||||
background: linear-gradient(180deg, #e2e8f0, #94a3b8);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.pod.r1 .pod-step {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.pod.r3 .pod-step {
|
||||
height: 34px;
|
||||
background: linear-gradient(180deg, #fed7aa, #c2410c);
|
||||
color: #431407;
|
||||
}
|
||||
|
||||
.pod.me .pod-name {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.crown {
|
||||
color: #fbbf24;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
|
||||
animation: bob 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.pod-empty {
|
||||
margin: auto 0;
|
||||
color: #a5f3fc;
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stat.coin strong {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.stat small {
|
||||
font-size: 0.7rem;
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.gap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gap-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #2dd4bf, #a7f3d0);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.bar.warm {
|
||||
background: rgba(154, 52, 18, 0.15);
|
||||
}
|
||||
|
||||
.bar.warm i {
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.goal {
|
||||
background: linear-gradient(145deg, #fff7ed, #ffedd5);
|
||||
color: #7c2d12;
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.goal-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.goal-head strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.goal p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.next-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.next-reward span {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.next-reward small {
|
||||
font-size: 0.7rem;
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.next-reward b {
|
||||
font-size: var(--fs-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.next-reward em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
display: flex;
|
||||
gap: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.c-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.c-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.15rem;
|
||||
margin-bottom: -1px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--muted-light);
|
||||
font: inherit;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c-tab:hover {
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.c-tab.on {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-column: 1 / -1;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.pod {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
gap: var(--sp-4);
|
||||
margin-inline: calc(-1 * var(--sp-3));
|
||||
padding-inline: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ const PAGES: Array<[string, string]> = [
|
||||
['/notes', 'یادداشتها'],
|
||||
['/tickets', 'پشتیبانی'],
|
||||
['/loyalty', 'باشگاه مشتریان'],
|
||||
['/club', 'باشگاه امتیازات'],
|
||||
['/reports', 'گزارشها'],
|
||||
['/invoices', 'فاکتورها'],
|
||||
['/quotes', 'پیشفاکتورها'],
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/** رویدادهای امتیازدهی — موتور پاداش فقط به همین رویدادها گوش میدهد */
|
||||
export type GamificationEvent =
|
||||
| "LEAD_CREATED"
|
||||
| "LEAD_STAGE_CHANGED"
|
||||
| "LEAD_CONVERTED"
|
||||
| "CUSTOMER_CREATED"
|
||||
| "COMPANY_CREATED"
|
||||
| "DEAL_CREATED"
|
||||
| "DEAL_STAGE_CHANGED"
|
||||
| "DEAL_WON"
|
||||
| "DEAL_LOST"
|
||||
| "CALL_LOGGED"
|
||||
| "MEETING_CREATED"
|
||||
| "FOLLOW_UP_CREATED"
|
||||
| "NOTE_CREATED"
|
||||
| "ACTIVITY_CREATED"
|
||||
| "TASK_COMPLETED"
|
||||
| "ROUTINE_COMPLETED"
|
||||
| "QUOTE_CREATED"
|
||||
| "INVOICE_ISSUED"
|
||||
| "PAYMENT_REGISTERED"
|
||||
| "TICKET_CREATED"
|
||||
| "TICKET_RESOLVED";
|
||||
|
||||
export type GamificationFieldType = "number" | "string" | "boolean";
|
||||
|
||||
export interface GamificationFieldMeta {
|
||||
label: string;
|
||||
type: GamificationFieldType;
|
||||
/** مقادیر پیشنهادی برای فیلدهای متنی */
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
/** فیلدهای قابل استفاده در شرطهای Rule Builder */
|
||||
export const GAMIFICATION_FIELDS: Record<string, GamificationFieldMeta> = {
|
||||
amount: { label: "مبلغ", type: "number" },
|
||||
stageName: { label: "نام مرحله", type: "string" },
|
||||
source: { label: "منبع", type: "string" },
|
||||
activityType: {
|
||||
label: "نوع فعالیت",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "call", label: "تماس" },
|
||||
{ value: "meeting", label: "جلسه" },
|
||||
{ value: "task", label: "کار" },
|
||||
{ value: "note", label: "یادداشت" },
|
||||
],
|
||||
},
|
||||
typeName: { label: "عنوان نوع فعالیت", type: "string" },
|
||||
onTime: { label: "انجام بهموقع", type: "boolean" },
|
||||
isMandatory: { label: "اجباری", type: "boolean" },
|
||||
priority: {
|
||||
label: "اولویت",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "very_low", label: "خیلی کم" },
|
||||
{ value: "low", label: "کم" },
|
||||
{ value: "medium", label: "متوسط" },
|
||||
{ value: "normal", label: "عادی (تیکت)" },
|
||||
{ value: "high", label: "زیاد" },
|
||||
{ value: "very_high", label: "خیلی زیاد" },
|
||||
{ value: "urgent", label: "فوری (تیکت)" },
|
||||
],
|
||||
},
|
||||
hasDescription: { label: "توضیحات دارد", type: "boolean" },
|
||||
hour: { label: "ساعت انجام (۰ تا ۲۳)", type: "number" },
|
||||
weekday: { label: "روز هفته (۰=شنبه … ۶=جمعه)", type: "number" },
|
||||
};
|
||||
|
||||
const COMMON_FIELDS = ["hour", "weekday"];
|
||||
|
||||
export interface GamificationEventMeta {
|
||||
label: string;
|
||||
/** متن کوتاه برای فید امتیازات، مثل «معامله برنده شد» */
|
||||
feed: string;
|
||||
group: "leads" | "customers" | "sales" | "activities" | "finance" | "support";
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export const GAMIFICATION_EVENTS: Record<GamificationEvent, GamificationEventMeta> = {
|
||||
LEAD_CREATED: { label: "ثبت لید جدید", feed: "لید جدید ثبت شد", group: "leads", fields: ["source", "amount", ...COMMON_FIELDS] },
|
||||
LEAD_STAGE_CHANGED: { label: "تغییر مرحله لید", feed: "لید به مرحله بعد رفت", group: "leads", fields: ["stageName", "source", ...COMMON_FIELDS] },
|
||||
LEAD_CONVERTED: { label: "تبدیل لید به مشتری", feed: "لید به مشتری تبدیل شد", group: "leads", fields: ["source", "amount", ...COMMON_FIELDS] },
|
||||
CUSTOMER_CREATED: { label: "ثبت مشتری جدید", feed: "مشتری جدید ثبت شد", group: "customers", fields: ["source", ...COMMON_FIELDS] },
|
||||
COMPANY_CREATED: { label: "ثبت شرکت جدید", feed: "شرکت جدید ثبت شد", group: "customers", fields: [...COMMON_FIELDS] },
|
||||
DEAL_CREATED: { label: "ایجاد معامله", feed: "معامله جدید ایجاد شد", group: "sales", fields: ["amount", ...COMMON_FIELDS] },
|
||||
DEAL_STAGE_CHANGED: { label: "جابهجایی معامله در مسیر فروش", feed: "معامله به مرحله بعد رفت", group: "sales", fields: ["stageName", "amount", ...COMMON_FIELDS] },
|
||||
DEAL_WON: { label: "برنده شدن معامله", feed: "معامله برنده شد", group: "sales", fields: ["amount", "stageName", ...COMMON_FIELDS] },
|
||||
DEAL_LOST: { label: "از دست رفتن معامله", feed: "معامله بسته شد", group: "sales", fields: ["amount", ...COMMON_FIELDS] },
|
||||
CALL_LOGGED: { label: "ثبت تماس", feed: "تماس ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] },
|
||||
MEETING_CREATED: { label: "ثبت جلسه", feed: "جلسه ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] },
|
||||
FOLLOW_UP_CREATED: { label: "ثبت پیگیری", feed: "پیگیری ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] },
|
||||
NOTE_CREATED: { label: "ثبت یادداشت", feed: "یادداشت ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] },
|
||||
ACTIVITY_CREATED: { label: "ثبت فعالیت", feed: "فعالیت ثبت شد", group: "activities", fields: ["activityType", "typeName", "priority", "hasDescription", ...COMMON_FIELDS] },
|
||||
TASK_COMPLETED: { label: "انجام کار", feed: "کار انجام شد", group: "activities", fields: ["activityType", "typeName", "onTime", "priority", ...COMMON_FIELDS] },
|
||||
ROUTINE_COMPLETED: { label: "انجام کار روتین", feed: "کار روتین انجام شد", group: "activities", fields: ["onTime", "isMandatory", "priority", ...COMMON_FIELDS] },
|
||||
QUOTE_CREATED: { label: "ثبت پیشفاکتور", feed: "پیشفاکتور ثبت شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] },
|
||||
INVOICE_ISSUED: { label: "صدور فاکتور", feed: "فاکتور صادر شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] },
|
||||
PAYMENT_REGISTERED: { label: "ثبت پرداخت", feed: "پرداخت ثبت شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] },
|
||||
TICKET_CREATED: { label: "ثبت تیکت", feed: "تیکت ثبت شد", group: "support", fields: ["priority", ...COMMON_FIELDS] },
|
||||
TICKET_RESOLVED: { label: "حل تیکت", feed: "تیکت حل شد", group: "support", fields: ["priority", ...COMMON_FIELDS] },
|
||||
};
|
||||
|
||||
export const GAMIFICATION_EVENT_KEYS = Object.keys(GAMIFICATION_EVENTS) as GamificationEvent[];
|
||||
|
||||
export const GAMIFICATION_EVENT_GROUP_LABELS: Record<GamificationEventMeta["group"], string> = {
|
||||
leads: "لیدها",
|
||||
customers: "مشتریان",
|
||||
sales: "فروش",
|
||||
activities: "فعالیتها",
|
||||
finance: "مالی",
|
||||
support: "پشتیبانی",
|
||||
};
|
||||
|
||||
export function isGamificationEvent(value: unknown): value is GamificationEvent {
|
||||
return typeof value === "string" && value in GAMIFICATION_EVENTS;
|
||||
}
|
||||
|
||||
export type GamificationConditionOp =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "contains"
|
||||
| "in"
|
||||
| "is_true"
|
||||
| "is_false";
|
||||
|
||||
export const GAMIFICATION_CONDITION_OPS: Record<GamificationConditionOp, string> = {
|
||||
eq: "برابر",
|
||||
neq: "مخالف",
|
||||
gt: "بیشتر از",
|
||||
gte: "حداقل",
|
||||
lt: "کمتر از",
|
||||
lte: "حداکثر",
|
||||
contains: "شامل",
|
||||
in: "یکی از (با ویرگول)",
|
||||
is_true: "بله باشد",
|
||||
is_false: "خیر باشد",
|
||||
};
|
||||
|
||||
export interface GamificationCondition {
|
||||
field: string;
|
||||
op: GamificationConditionOp;
|
||||
value?: string | number | null;
|
||||
}
|
||||
|
||||
export type GamificationPeriod = "today" | "week" | "month" | "quarter" | "year" | "all";
|
||||
|
||||
export const GAMIFICATION_PERIOD_LABELS: Record<GamificationPeriod, string> = {
|
||||
today: "امروز",
|
||||
week: "این هفته",
|
||||
month: "این ماه",
|
||||
quarter: "این فصل",
|
||||
year: "امسال",
|
||||
all: "همه زمانها",
|
||||
};
|
||||
|
||||
export type GamificationTxKind = "rule" | "bonus" | "achievement" | "adjust" | "redeem" | "refund";
|
||||
export type GamificationTxStatus = "approved" | "pending" | "rejected";
|
||||
|
||||
export interface GamificationLevelDef {
|
||||
name: string;
|
||||
minScore: number;
|
||||
}
|
||||
|
||||
export interface GamificationLevelDto {
|
||||
level: number;
|
||||
name: string;
|
||||
minScore: number;
|
||||
nextName: string | null;
|
||||
nextMinScore: number | null;
|
||||
/** درصد پیشرفت تا سطح بعد */
|
||||
progress: number;
|
||||
toNext: number | null;
|
||||
}
|
||||
|
||||
export interface GamificationRuleDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
event: GamificationEvent;
|
||||
score: number;
|
||||
coins: number;
|
||||
conditions: GamificationCondition[];
|
||||
isActive: boolean;
|
||||
maxPerDay: number | null;
|
||||
maxCoinsPerDay: number | null;
|
||||
maxPerUser: number | null;
|
||||
cooldownMinutes: number | null;
|
||||
oncePerEntity: boolean;
|
||||
firstTimeOnly: boolean;
|
||||
requiresApproval: boolean;
|
||||
/** نقشهای مشمول؛ خالی = همه */
|
||||
roles: string[];
|
||||
/** تیمهای مشمول؛ خالی = همه */
|
||||
teamIds: string[];
|
||||
position: number;
|
||||
awardedCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SaveGamificationRuleRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
event: GamificationEvent;
|
||||
score: number;
|
||||
coins: number;
|
||||
conditions?: GamificationCondition[];
|
||||
isActive?: boolean;
|
||||
maxPerDay?: number | null;
|
||||
maxCoinsPerDay?: number | null;
|
||||
maxPerUser?: number | null;
|
||||
cooldownMinutes?: number | null;
|
||||
oncePerEntity?: boolean;
|
||||
firstTimeOnly?: boolean;
|
||||
requiresApproval?: boolean;
|
||||
roles?: string[];
|
||||
teamIds?: string[];
|
||||
}
|
||||
|
||||
export interface GamificationTxDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
kind: GamificationTxKind;
|
||||
event: GamificationEvent | null;
|
||||
title: string;
|
||||
score: number;
|
||||
coins: number;
|
||||
status: GamificationTxStatus;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
entityTitle: string | null;
|
||||
/** لینک پرونده مرتبط در CRM */
|
||||
link: string | null;
|
||||
reason: string | null;
|
||||
createdByName: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GamificationLeaderEntryDto {
|
||||
rank: number;
|
||||
userId: string;
|
||||
fullName: string;
|
||||
hasAvatar: boolean;
|
||||
score: number;
|
||||
/** تغییر رتبه نسبت به ۲۴ ساعت قبل؛ مثبت = صعود */
|
||||
change: number | null;
|
||||
levelName: string;
|
||||
isMe: boolean;
|
||||
}
|
||||
|
||||
export interface GamificationLeaderboardDto {
|
||||
period: GamificationPeriod;
|
||||
from: string | null;
|
||||
totalUsers: number;
|
||||
items: GamificationLeaderEntryDto[];
|
||||
/** کاربر جاری همیشه برمیگردد، حتی اگر در فهرست نباشد */
|
||||
me: GamificationLeaderEntryDto | null;
|
||||
}
|
||||
|
||||
export interface GamificationTeamEntryDto {
|
||||
rank: number;
|
||||
teamId: string;
|
||||
name: string;
|
||||
members: number;
|
||||
score: number;
|
||||
avgScore: number;
|
||||
isMine: boolean;
|
||||
}
|
||||
|
||||
export interface GamificationRewardDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
cost: number;
|
||||
icon: string;
|
||||
color: string;
|
||||
stock: number | null;
|
||||
isActive: boolean;
|
||||
requiresApproval: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface SaveGamificationRewardRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
cost: number;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
stock?: number | null;
|
||||
isActive?: boolean;
|
||||
requiresApproval?: boolean;
|
||||
}
|
||||
|
||||
export type GamificationRedemptionStatus = "pending" | "approved" | "rejected" | "delivered";
|
||||
|
||||
export const GAMIFICATION_REDEMPTION_LABELS: Record<GamificationRedemptionStatus, string> = {
|
||||
pending: "در انتظار تأیید",
|
||||
approved: "تأیید شده",
|
||||
rejected: "رد شده",
|
||||
delivered: "تحویل شده",
|
||||
};
|
||||
|
||||
export interface GamificationRedemptionDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
rewardId: string;
|
||||
rewardName: string;
|
||||
rewardIcon: string;
|
||||
cost: number;
|
||||
status: GamificationRedemptionStatus;
|
||||
note: string | null;
|
||||
decisionNote: string | null;
|
||||
decidedByName: string | null;
|
||||
decidedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type GamificationBadgeMetric =
|
||||
| "event_count"
|
||||
| "score_total"
|
||||
| "routine_streak"
|
||||
| "single_deal_amount";
|
||||
|
||||
export const GAMIFICATION_BADGE_METRICS: Record<GamificationBadgeMetric, string> = {
|
||||
event_count: "تعداد دفعات یک رویداد",
|
||||
score_total: "مجموع امتیاز",
|
||||
routine_streak: "روزهای پیاپی انجام کامل روتینها",
|
||||
single_deal_amount: "مبلغ یک معامله برنده",
|
||||
};
|
||||
|
||||
export interface GamificationBadgeDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
metric: GamificationBadgeMetric;
|
||||
event: GamificationEvent | null;
|
||||
threshold: number;
|
||||
coins: number;
|
||||
isActive: boolean;
|
||||
earnedCount: number;
|
||||
}
|
||||
|
||||
export interface SaveGamificationBadgeRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
metric: GamificationBadgeMetric;
|
||||
event?: GamificationEvent | null;
|
||||
threshold: number;
|
||||
coins?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface GamificationAchievementDto extends GamificationBadgeDto {
|
||||
earned: boolean;
|
||||
earnedAt: string | null;
|
||||
/** مقدار فعلی کاربر برای این شاخص */
|
||||
current: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export type GamificationBonusKind = "event_count_period" | "routine_streak" | "top_of_month";
|
||||
|
||||
export const GAMIFICATION_BONUS_KINDS: Record<GamificationBonusKind, string> = {
|
||||
event_count_period: "تعداد رویداد در بازه",
|
||||
routine_streak: "روزهای پیاپی انجام کامل روتین",
|
||||
top_of_month: "نفر اول امتیاز ماه",
|
||||
};
|
||||
|
||||
export interface GamificationBonusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
kind: GamificationBonusKind;
|
||||
event: GamificationEvent | null;
|
||||
threshold: number;
|
||||
period: "day" | "week" | "month";
|
||||
score: number;
|
||||
coins: number;
|
||||
isActive: boolean;
|
||||
awardedCount: number;
|
||||
}
|
||||
|
||||
export interface SaveGamificationBonusRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind: GamificationBonusKind;
|
||||
event?: GamificationEvent | null;
|
||||
threshold: number;
|
||||
period?: "day" | "week" | "month";
|
||||
score?: number;
|
||||
coins?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface GamificationSettingsDto {
|
||||
enabled: boolean;
|
||||
levels: GamificationLevelDef[];
|
||||
/** هدف ماهانه امتیاز برای کارت «هدف بعدی» */
|
||||
monthlyGoal: number;
|
||||
/** نمایش کامل جدول برای همه؛ اگر خاموش باشد فقط ۱۰ نفر اول و خود کاربر */
|
||||
showFullLeaderboard: boolean;
|
||||
enabledAt: string | null;
|
||||
}
|
||||
|
||||
export interface GamificationMeDto {
|
||||
user: { id: string; fullName: string; hasAvatar: boolean };
|
||||
isManager: boolean;
|
||||
settings: GamificationSettingsDto;
|
||||
score: number;
|
||||
coins: number;
|
||||
period: GamificationPeriod;
|
||||
periodScore: number;
|
||||
todayScore: number;
|
||||
rank: number | null;
|
||||
totalUsers: number;
|
||||
/** فاصله تا نفر بالاتر */
|
||||
gapToNext: number | null;
|
||||
nextRankName: string | null;
|
||||
gapToFirst: number | null;
|
||||
level: GamificationLevelDto;
|
||||
/** بهترین امتیاز ماهانه کاربر تا امروز */
|
||||
personalBest: number;
|
||||
goal: { target: number; current: number; progress: number };
|
||||
nextReward: { id: string; name: string; cost: number; icon: string; remaining: number } | null;
|
||||
streakDays: number;
|
||||
badgesEarned: number;
|
||||
pendingRedemptions: number;
|
||||
recent: GamificationTxDto[];
|
||||
}
|
||||
|
||||
export interface GamificationPulseDto {
|
||||
serverTime: string;
|
||||
score: number;
|
||||
coins: number;
|
||||
rank: number | null;
|
||||
items: GamificationTxDto[];
|
||||
}
|
||||
|
||||
export interface GamificationAdjustRequest {
|
||||
userId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface GamificationReportUserRow {
|
||||
userId: string;
|
||||
fullName: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface GamificationRoiMetric {
|
||||
key: string;
|
||||
label: string;
|
||||
before: number;
|
||||
after: number;
|
||||
/** درصد تغییر؛ null وقتی مبنا صفر است */
|
||||
changePct: number | null;
|
||||
}
|
||||
|
||||
export interface GamificationReportDto {
|
||||
period: GamificationPeriod;
|
||||
totals: {
|
||||
score: number;
|
||||
coinsEarned: number;
|
||||
coinsSpent: number;
|
||||
activeUsers: number;
|
||||
totalUsers: number;
|
||||
transactions: number;
|
||||
pendingApprovals: number;
|
||||
pendingRedemptions: number;
|
||||
};
|
||||
byEvent: Array<{ event: GamificationEvent; label: string; count: number; score: number; coins: number }>;
|
||||
topActive: GamificationReportUserRow[];
|
||||
topGrowth: GamificationReportUserRow[];
|
||||
topDeals: GamificationReportUserRow[];
|
||||
topLeads: GamificationReportUserRow[];
|
||||
topTasks: GamificationReportUserRow[];
|
||||
inactive: GamificationReportUserRow[];
|
||||
roi: { since: string | null; windowDays: number; metrics: GamificationRoiMetric[] };
|
||||
}
|
||||
@@ -139,7 +139,8 @@ export type OrgModuleKey =
|
||||
| "reservations"
|
||||
| "leads"
|
||||
| "conversations"
|
||||
| "routines";
|
||||
| "routines"
|
||||
| "gamification";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -178,6 +179,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"reservations",
|
||||
"conversations",
|
||||
"routines",
|
||||
"gamification",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -217,6 +219,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
reservations: "رزرو و نوبتدهی",
|
||||
conversations: "گفتگو",
|
||||
routines: "کارهای روتین",
|
||||
gamification: "باشگاه امتیازات",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
@@ -236,7 +239,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
"sales_forecast",
|
||||
],
|
||||
},
|
||||
{ title: "فعالیتها", keys: ["activities", "routines", "notes"] },
|
||||
{ title: "فعالیتها", keys: ["activities", "routines", "notes", "gamification"] },
|
||||
{ title: "پشتیبانی", keys: ["tickets", "faq", "conversations"] },
|
||||
{
|
||||
title: "اتوماسیون",
|
||||
@@ -5211,3 +5214,7 @@ export interface RoutineUserSettingsDto {
|
||||
/** ساعت ارسال خلاصه پایان روز (۰ تا ۲۳) */
|
||||
summaryHour: number;
|
||||
}
|
||||
|
||||
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
|
||||
|
||||
export * from "./gamification";
|
||||
|
||||
Reference in New Issue
Block a user