feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)

- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 21:28:49 +03:30
co-authored by Cursor
parent b87ebb22e0
commit 60d797f184
51 changed files with 11298 additions and 806 deletions
+1 -1
View File
@@ -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"));
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,
+2
View File
@@ -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 {}
+15 -1
View File
@@ -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);
}
+13
View File
@@ -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,
+341
View File
@@ -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
+23 -1
View File
@@ -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(
+65 -2
View File
@@ -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,
+11
View File
@@ -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);
}
+29
View File
@@ -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);
+16 -2
View File
@@ -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);
}
+20
View File
@@ -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);
+196
View File
@@ -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}`;
}
+28 -3
View File
@@ -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>
+103
View File
@@ -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: 'پشتیبانی',
};
+9 -9
View File
@@ -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',
},
],
},
{
+10
View File
@@ -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>
+1
View File
@@ -13,6 +13,7 @@ const PAGES: Array<[string, string]> = [
['/notes', 'یادداشت‌ها'],
['/tickets', 'پشتیبانی'],
['/loyalty', 'باشگاه مشتریان'],
['/club', 'باشگاه امتیازات'],
['/reports', 'گزارش‌ها'],
['/invoices', 'فاکتورها'],
['/quotes', 'پیش‌فاکتورها'],
+491
View File
@@ -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[] };
}
+9 -2
View File
@@ -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";