From d133c8ca343372a602607315e4a88514858343d3 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 27 Sep 2026 09:38:17 +0330 Subject: [PATCH] Add reservations module and rename teams visibility label to file-access level. Co-authored-by: Cursor --- .../20260927120000_reservations/migration.sql | 66 +++ apps/api/prisma/schema.prisma | 49 ++ apps/api/src/app.module.ts | 2 + apps/api/src/reservations/dto.ts | 205 +++++++ .../reservations/reservations.controller.ts | 118 ++++ .../src/reservations/reservations.module.ts | 13 + .../src/reservations/reservations.service.ts | 509 ++++++++++++++++ apps/api/src/users/users.service.ts | 4 +- .../web/src/components/TeamsSettingsPanel.vue | 16 +- apps/web/src/layouts/AppLayout.vue | 10 +- apps/web/src/lib/nav-hubs.ts | 13 +- apps/web/src/router/index.ts | 5 + apps/web/src/views/ReservationsView.vue | 546 ++++++++++++++++++ apps/web/src/views/TeamSettingsView.vue | 2 +- packages/shared/src/index.ts | 103 +++- 15 files changed, 1646 insertions(+), 15 deletions(-) create mode 100644 apps/api/prisma/migrations/20260927120000_reservations/migration.sql create mode 100644 apps/api/src/reservations/dto.ts create mode 100644 apps/api/src/reservations/reservations.controller.ts create mode 100644 apps/api/src/reservations/reservations.module.ts create mode 100644 apps/api/src/reservations/reservations.service.ts create mode 100644 apps/web/src/views/ReservationsView.vue diff --git a/apps/api/prisma/migrations/20260927120000_reservations/migration.sql b/apps/api/prisma/migrations/20260927120000_reservations/migration.sql new file mode 100644 index 0000000..a39b77f --- /dev/null +++ b/apps/api/prisma/migrations/20260927120000_reservations/migration.sql @@ -0,0 +1,66 @@ +-- CreateTable +CREATE TABLE "ReservationService" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "durationMinutes" INTEGER NOT NULL DEFAULT 30, + "workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[], + "dayStartHour" INTEGER NOT NULL DEFAULT 9, + "dayEndHour" INTEGER NOT NULL DEFAULT 17, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReservationAppointment" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "serviceId" TEXT NOT NULL, + "personId" TEXT NOT NULL, + "assigneeId" TEXT, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive"); + +-- CreateIndex +CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt"); + +-- CreateIndex +CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status"); + +-- CreateIndex +CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt"); + +-- CreateIndex +CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId"); + +-- CreateIndex +CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt"); + +-- AddForeignKey +ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 59ecd97..f9b8380 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -200,6 +200,8 @@ model Organization { smsCampaigns SmsCampaign[] wooCommerceConnections WooCommerceConnection[] voipConnections VoipConnection[] + reservationServices ReservationService[] + reservationAppointments ReservationAppointment[] } model User { @@ -245,6 +247,7 @@ model User { savedViews SavedView[] @relation("SavedViewOwner") assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") teamMemberships TeamMember[] + reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -425,6 +428,7 @@ model Person { smartNotes Note[] quotes Quote[] invoices Invoice[] + reservationAppointments ReservationAppointment[] ownerId String? owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull) tickets Ticket[] @@ -1830,3 +1834,48 @@ model VoipCallLog { @@index([personId]) @@index([activityId]) } + +/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای) +model ReservationService { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String @default("") + durationMinutes Int @default(30) + /// روزهای کار ۰=یکشنبه … ۶=شنبه + workDays Int[] @default([0, 1, 2, 3, 4, 5, 6]) + dayStartHour Int @default(9) + dayEndHour Int @default(17) + isActive Boolean @default(true) + appointments ReservationAppointment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, isActive]) +} + +model ReservationAppointment { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + serviceId String + service ReservationService @relation(fields: [serviceId], references: [id], onDelete: Restrict) + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + assigneeId String? + assignee User? @relation("ReservationAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + startsAt DateTime + endsAt DateTime + /// pending | confirmed | canceled | done + status String @default("pending") + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, startsAt]) + @@index([organizationId, status]) + @@index([serviceId, startsAt]) + @@index([personId]) + @@index([assigneeId, startsAt]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9381dd8..8713bed 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -41,6 +41,7 @@ import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module'; import { TeamsModule } from './teams/teams.module'; import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module'; import { VoipModule } from './integrations/voip/voip.module'; +import { ReservationsModule } from './reservations/reservations.module'; @Module({ imports: [ @@ -89,6 +90,7 @@ import { VoipModule } from './integrations/voip/voip.module'; TeamsModule, WooCommerceModule, VoipModule, + ReservationsModule, ], }) export class AppModule {} diff --git a/apps/api/src/reservations/dto.ts b/apps/api/src/reservations/dto.ts new file mode 100644 index 0000000..c52a3e8 --- /dev/null +++ b/apps/api/src/reservations/dto.ts @@ -0,0 +1,205 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +const STATUSES = ['pending', 'confirmed', 'canceled', 'done'] as const; + +export class CreateReservationServiceDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @Type(() => Number) + @IsInt() + @Min(5) + @Max(480) + durationMinutes!: number; + + @IsOptional() + @IsArray() + @ArrayMaxSize(7) + @IsInt({ each: true }) + @Min(0, { each: true }) + @Max(6, { each: true }) + workDays?: number[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + dayStartHour?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24) + dayEndHour?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateReservationServiceDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(5) + @Max(480) + durationMinutes?: number; + + @IsOptional() + @IsArray() + @ArrayMaxSize(7) + @IsInt({ each: true }) + @Min(0, { each: true }) + @Max(6, { each: true }) + workDays?: number[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + dayStartHour?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24) + dayEndHour?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateReservationAppointmentDto { + @IsString() + serviceId!: string; + + @IsString() + personId!: string; + + @IsOptional() + @IsString() + assigneeId?: string | null; + + @IsDateString() + startsAt!: string; + + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string | null; +} + +export class UpdateReservationAppointmentDto { + @IsOptional() + @IsString() + serviceId?: string; + + @IsOptional() + @IsString() + personId?: string; + + @IsOptional() + @IsString() + assigneeId?: string | null; + + @IsOptional() + @IsDateString() + startsAt?: string; + + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string | null; +} + +export class ListReservationsQueryDto { + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsDateString() + from?: string; + + @IsOptional() + @IsDateString() + to?: string; + + @IsOptional() + @IsString() + serviceId?: string; + + @IsOptional() + @IsString() + personId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 30; +} + +export class SlotsQueryDto { + @IsString() + serviceId!: string; + + @IsDateString() + date!: string; + + @IsOptional() + @IsString() + assigneeId?: string; +} diff --git a/apps/api/src/reservations/reservations.controller.ts b/apps/api/src/reservations/reservations.controller.ts new file mode 100644 index 0000000..e934c49 --- /dev/null +++ b/apps/api/src/reservations/reservations.controller.ts @@ -0,0 +1,118 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; +import type { Actor } from '../common/record-scope'; +import { + CreateReservationAppointmentDto, + CreateReservationServiceDto, + ListReservationsQueryDto, + SlotsQueryDto, + UpdateReservationAppointmentDto, + UpdateReservationServiceDto, +} from './dto'; +import { ReservationsService } from './reservations.service'; + +@Controller('reservations') +@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) +@RequireOrgModule('reservations') +export class ReservationsController { + constructor(private readonly reservations: ReservationsService) {} + + @Get('services') + listServices(@CurrentUser() user: Actor) { + return this.reservations.listServices(user.organizationId); + } + + @Post('services') + @Roles('owner', 'admin') + createService( + @CurrentUser() user: Actor, + @Body() dto: CreateReservationServiceDto, + ) { + return this.reservations.createService(user.organizationId, dto); + } + + @Patch('services/:id') + @Roles('owner', 'admin') + updateService( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateReservationServiceDto, + ) { + return this.reservations.updateService(user.organizationId, id, dto); + } + + @Delete('services/:id') + @Roles('owner', 'admin') + async removeService( + @CurrentUser() user: Actor, + @Param('id') id: string, + ) { + await this.reservations.removeService(user.organizationId, id); + return { ok: true }; + } + + @Get('slots') + slots(@CurrentUser() user: Actor, @Query() query: SlotsQueryDto) { + return this.reservations.listSlots( + user.organizationId, + query.serviceId, + query.date, + query.assigneeId, + ); + } + + @Get() + list( + @CurrentUser() user: Actor, + @Query() query: ListReservationsQueryDto, + ) { + return this.reservations.listAppointments(user.organizationId, user, query); + } + + @Post() + create( + @CurrentUser() user: Actor, + @Body() dto: CreateReservationAppointmentDto, + ) { + return this.reservations.createAppointment( + user.organizationId, + user, + dto, + ); + } + + @Patch(':id') + update( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateReservationAppointmentDto, + ) { + return this.reservations.updateAppointment( + user.organizationId, + user, + id, + dto, + ); + } + + @Delete(':id') + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { + await this.reservations.removeAppointment(user.organizationId, user, id); + return { ok: true }; + } +} diff --git a/apps/api/src/reservations/reservations.module.ts b/apps/api/src/reservations/reservations.module.ts new file mode 100644 index 0000000..914efb2 --- /dev/null +++ b/apps/api/src/reservations/reservations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BillingModule } from '../billing/billing.module'; +import { ReservationsController } from './reservations.controller'; +import { ReservationsService } from './reservations.service'; + +@Module({ + imports: [AuthModule, BillingModule], + controllers: [ReservationsController], + providers: [ReservationsService], + exports: [ReservationsService], +}) +export class ReservationsModule {} diff --git a/apps/api/src/reservations/reservations.service.ts b/apps/api/src/reservations/reservations.service.ts new file mode 100644 index 0000000..f661163 --- /dev/null +++ b/apps/api/src/reservations/reservations.service.ts @@ -0,0 +1,509 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateReservationAppointmentRequest, + CreateReservationServiceRequest, + ReservationAppointmentDto, + ReservationListResponse, + ReservationServiceDto, + ReservationSlotDto, + ReservationStatus, + UpdateReservationAppointmentRequest, + UpdateReservationServiceRequest, +} from '@mento/shared'; +import { RESERVATION_STATUSES } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { type Actor, elevated } from '../common/record-scope'; + +@Injectable() +export class ReservationsService { + constructor(private readonly prisma: PrismaService) {} + + /* ——— services ——— */ + + async listServices(organizationId: string): Promise { + const rows = await this.prisma.reservationService.findMany({ + where: { organizationId }, + orderBy: [{ isActive: 'desc' }, { name: 'asc' }], + }); + return rows.map((r) => this.toServiceDto(r)); + } + + async createService( + organizationId: string, + dto: CreateReservationServiceRequest, + ): Promise { + const name = dto.name?.trim(); + if (!name) throw new BadRequestException('نام خدمت الزامی است'); + const duration = Number(dto.durationMinutes); + if (!Number.isFinite(duration) || duration < 5) { + throw new BadRequestException('مدت خدمت نامعتبر است'); + } + const dayStart = dto.dayStartHour ?? 9; + const dayEnd = dto.dayEndHour ?? 17; + if (dayEnd <= dayStart) { + throw new BadRequestException('ساعت پایان باید بعد از شروع باشد'); + } + const row = await this.prisma.reservationService.create({ + data: { + organizationId, + name, + description: dto.description?.trim() ?? '', + durationMinutes: duration, + workDays: dto.workDays?.length ? dto.workDays : [0, 1, 2, 3, 4, 5, 6], + dayStartHour: dayStart, + dayEndHour: dayEnd, + isActive: dto.isActive ?? true, + }, + }); + return this.toServiceDto(row); + } + + async updateService( + organizationId: string, + id: string, + dto: UpdateReservationServiceRequest, + ): Promise { + const existing = await this.getServiceOrThrow(organizationId, id); + const data: Prisma.ReservationServiceUpdateInput = {}; + if (dto.name !== undefined) { + const name = dto.name.trim(); + if (!name) throw new BadRequestException('نام خدمت الزامی است'); + data.name = name; + } + if (dto.description !== undefined) data.description = dto.description.trim(); + if (dto.durationMinutes !== undefined) { + if (dto.durationMinutes < 5) throw new BadRequestException('مدت نامعتبر است'); + data.durationMinutes = dto.durationMinutes; + } + if (dto.workDays !== undefined) data.workDays = dto.workDays; + if (dto.dayStartHour !== undefined) data.dayStartHour = dto.dayStartHour; + if (dto.dayEndHour !== undefined) data.dayEndHour = dto.dayEndHour; + if (dto.isActive !== undefined) data.isActive = dto.isActive; + + const nextStart = + dto.dayStartHour !== undefined ? dto.dayStartHour : existing.dayStartHour; + const nextEnd = + dto.dayEndHour !== undefined ? dto.dayEndHour : existing.dayEndHour; + if (nextEnd <= nextStart) { + throw new BadRequestException('ساعت پایان باید بعد از شروع باشد'); + } + + const row = await this.prisma.reservationService.update({ + where: { id }, + data, + }); + return this.toServiceDto(row); + } + + async removeService(organizationId: string, id: string): Promise { + await this.getServiceOrThrow(organizationId, id); + const count = await this.prisma.reservationAppointment.count({ + where: { + serviceId: id, + status: { in: ['pending', 'confirmed'] }, + }, + }); + if (count) { + throw new BadRequestException( + 'خدمت دارای نوبت فعال است؛ ابتدا نوبت‌ها را لغو کنید یا خدمت را غیرفعال کنید', + ); + } + await this.prisma.reservationService.delete({ where: { id } }); + } + + /* ——— slots ——— */ + + async listSlots( + organizationId: string, + serviceId: string, + dateIso: string, + assigneeId?: string, + ): Promise { + const service = await this.getServiceOrThrow(organizationId, serviceId); + if (!service.isActive) return []; + + const dayStart = this.parseDayStart(dateIso); + const weekday = dayStart.getDay(); + if (!service.workDays.includes(weekday)) return []; + + const windowStart = new Date(dayStart); + windowStart.setHours(service.dayStartHour, 0, 0, 0); + const windowEnd = new Date(dayStart); + windowEnd.setHours(service.dayEndHour, 0, 0, 0); + + const busy = await this.prisma.reservationAppointment.findMany({ + where: { + organizationId, + status: { in: ['pending', 'confirmed'] }, + startsAt: { lt: windowEnd }, + endsAt: { gt: windowStart }, + ...(assigneeId ? { assigneeId } : { serviceId }), + }, + select: { startsAt: true, endsAt: true }, + }); + + const slots: ReservationSlotDto[] = []; + const stepMs = service.durationMinutes * 60_000; + for ( + let t = windowStart.getTime(); + t + stepMs <= windowEnd.getTime(); + t += stepMs + ) { + const startsAt = new Date(t); + const endsAt = new Date(t + stepMs); + const overlap = busy.some( + (b) => b.startsAt < endsAt && b.endsAt > startsAt, + ); + if (!overlap) { + slots.push({ + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + }); + } + } + return slots; + } + + /* ——— appointments ——— */ + + async listAppointments( + organizationId: string, + actor: Actor, + query: { + status?: string; + from?: string; + to?: string; + serviceId?: string; + personId?: string; + page?: number; + limit?: number; + }, + ): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? 30; + const where: Prisma.ReservationAppointmentWhereInput = { + organizationId, + ...(query.status ? { status: query.status } : {}), + ...(query.serviceId ? { serviceId: query.serviceId } : {}), + ...(query.personId ? { personId: query.personId } : {}), + ...(query.from || query.to + ? { + startsAt: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + ...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}), + }; + + const [total, rows] = await Promise.all([ + this.prisma.reservationAppointment.count({ where }), + this.prisma.reservationAppointment.findMany({ + where, + include: this.appointmentInclude, + orderBy: { startsAt: 'asc' }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + return { + total, + items: rows.map((r) => this.toAppointmentDto(r)), + }; + } + + async createAppointment( + organizationId: string, + actor: Actor, + dto: CreateReservationAppointmentRequest, + ): Promise { + const service = await this.getServiceOrThrow(organizationId, dto.serviceId); + if (!service.isActive) throw new BadRequestException('این خدمت فعال نیست'); + + await this.assertPerson(organizationId, dto.personId); + const assigneeId = await this.resolveAssignee( + organizationId, + actor, + dto.assigneeId, + ); + + const startsAt = new Date(dto.startsAt); + if (Number.isNaN(startsAt.getTime())) { + throw new BadRequestException('زمان شروع نامعتبر است'); + } + const endsAt = new Date( + startsAt.getTime() + service.durationMinutes * 60_000, + ); + + await this.assertNoConflict( + organizationId, + service.id, + startsAt, + endsAt, + assigneeId, + ); + + const status = this.normalizeStatus(dto.status ?? 'pending'); + const row = await this.prisma.reservationAppointment.create({ + data: { + organizationId, + serviceId: service.id, + personId: dto.personId, + assigneeId, + startsAt, + endsAt, + status, + notes: dto.notes?.trim() || null, + }, + include: this.appointmentInclude, + }); + return this.toAppointmentDto(row); + } + + async updateAppointment( + organizationId: string, + actor: Actor, + id: string, + dto: UpdateReservationAppointmentRequest, + ): Promise { + const existing = await this.prisma.reservationAppointment.findFirst({ + where: { + id, + organizationId, + ...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}), + }, + }); + if (!existing) throw new NotFoundException('نوبت پیدا نشد'); + + let serviceId = existing.serviceId; + let startsAt = existing.startsAt; + let endsAt = existing.endsAt; + let assigneeId = existing.assigneeId; + let personId = existing.personId; + + if (dto.serviceId) { + const service = await this.getServiceOrThrow(organizationId, dto.serviceId); + serviceId = service.id; + endsAt = new Date( + startsAt.getTime() + service.durationMinutes * 60_000, + ); + } + if (dto.startsAt) { + startsAt = new Date(dto.startsAt); + if (Number.isNaN(startsAt.getTime())) { + throw new BadRequestException('زمان شروع نامعتبر است'); + } + const service = await this.getServiceOrThrow(organizationId, serviceId); + endsAt = new Date( + startsAt.getTime() + service.durationMinutes * 60_000, + ); + } + if (dto.personId) { + await this.assertPerson(organizationId, dto.personId); + personId = dto.personId; + } + if (dto.assigneeId !== undefined) { + assigneeId = await this.resolveAssignee( + organizationId, + actor, + dto.assigneeId, + ); + } + + if (dto.startsAt || dto.serviceId || dto.assigneeId !== undefined) { + await this.assertNoConflict( + organizationId, + serviceId, + startsAt, + endsAt, + assigneeId, + id, + ); + } + + const row = await this.prisma.reservationAppointment.update({ + where: { id }, + data: { + serviceId, + personId, + assigneeId, + startsAt, + endsAt, + ...(dto.status !== undefined + ? { status: this.normalizeStatus(dto.status) } + : {}), + ...(dto.notes !== undefined + ? { notes: dto.notes?.trim() || null } + : {}), + }, + include: this.appointmentInclude, + }); + return this.toAppointmentDto(row); + } + + async removeAppointment( + organizationId: string, + actor: Actor, + id: string, + ): Promise { + const existing = await this.prisma.reservationAppointment.findFirst({ + where: { + id, + organizationId, + ...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}), + }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('نوبت پیدا نشد'); + await this.prisma.reservationAppointment.delete({ where: { id } }); + } + + /* ——— helpers ——— */ + + private appointmentInclude = { + service: { select: { id: true, name: true } }, + person: { select: { id: true, firstName: true, lastName: true } }, + assignee: { select: { id: true, fullName: true } }, + } satisfies Prisma.ReservationAppointmentInclude; + + private async getServiceOrThrow(organizationId: string, id: string) { + const row = await this.prisma.reservationService.findFirst({ + where: { id, organizationId }, + }); + if (!row) throw new NotFoundException('خدمت پیدا نشد'); + return row; + } + + private async assertPerson(organizationId: string, personId: string) { + const p = await this.prisma.person.findFirst({ + where: { id: personId, organizationId }, + select: { id: true }, + }); + if (!p) throw new BadRequestException('مشتری معتبر نیست'); + } + + private async resolveAssignee( + organizationId: string, + actor: Actor, + requested?: string | null, + ): Promise { + if (!elevated(actor.role)) return actor.userId; + if (!requested?.trim()) return null; + const m = await this.prisma.membership.findFirst({ + where: { + organizationId, + userId: requested.trim(), + isActive: true, + }, + select: { userId: true }, + }); + if (!m) throw new BadRequestException('کارشناس معتبر نیست'); + return m.userId; + } + + private async assertNoConflict( + organizationId: string, + serviceId: string, + startsAt: Date, + endsAt: Date, + assigneeId: string | null, + exceptId?: string, + ) { + const conflict = await this.prisma.reservationAppointment.findFirst({ + where: { + organizationId, + status: { in: ['pending', 'confirmed'] }, + id: exceptId ? { not: exceptId } : undefined, + startsAt: { lt: endsAt }, + endsAt: { gt: startsAt }, + OR: [ + { serviceId }, + ...(assigneeId ? [{ assigneeId }] : []), + ], + }, + select: { id: true }, + }); + if (conflict) { + throw new BadRequestException('این بازه زمانی قبلاً رزرو شده است'); + } + } + + private normalizeStatus(value: string): ReservationStatus { + if (RESERVATION_STATUSES.includes(value as ReservationStatus)) { + return value as ReservationStatus; + } + throw new BadRequestException('وضعیت نامعتبر است'); + } + + private parseDayStart(dateIso: string): Date { + const d = new Date(dateIso); + if (Number.isNaN(d.getTime())) { + throw new BadRequestException('تاریخ نامعتبر است'); + } + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + private toServiceDto(row: { + id: string; + name: string; + description: string; + durationMinutes: number; + workDays: number[]; + dayStartHour: number; + dayEndHour: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + }): ReservationServiceDto { + return { + id: row.id, + name: row.name, + description: row.description, + durationMinutes: row.durationMinutes, + workDays: row.workDays, + dayStartHour: row.dayStartHour, + dayEndHour: row.dayEndHour, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + private toAppointmentDto(row: { + id: string; + serviceId: string; + personId: string; + assigneeId: string | null; + startsAt: Date; + endsAt: Date; + status: string; + notes: string | null; + createdAt: Date; + updatedAt: Date; + service: { name: string }; + person: { firstName: string; lastName: string }; + assignee: { fullName: string } | null; + }): ReservationAppointmentDto { + return { + id: row.id, + serviceId: row.serviceId, + serviceName: row.service.name, + personId: row.personId, + personName: `${row.person.firstName} ${row.person.lastName}`.trim(), + assigneeId: row.assigneeId, + assigneeName: row.assignee?.fullName ?? null, + startsAt: row.startsAt.toISOString(), + endsAt: row.endsAt.toISOString(), + status: row.status as ReservationStatus, + notes: row.notes, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 6650e5a..f993fab 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -169,10 +169,10 @@ export class UsersService { dataVisibility: DataVisibility, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { - throw new ForbiddenException('اجازه تغییر سطح دید را ندارید'); + throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید'); } if (!DATA_VISIBILITIES.includes(dataVisibility)) { - throw new BadRequestException('سطح دید نامعتبر است'); + throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است'); } const target = await this.memberships.findUserInOrgById( organizationId, diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index 5058f8e..6dad466 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -47,7 +47,7 @@ async function load() { teams.value = data; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 403) { - error.value = 'ماژول تیم‌ها و دیدپذیری برای این محیط فعال نیست.'; + error.value = 'ماژول تیم‌ها و سطح دسترسی به پرونده‌ها برای این محیط فعال نیست.'; } else { error.value = 'بارگذاری تیم‌ها ناموفق بود.'; } @@ -140,12 +140,16 @@ async function removeTeam(id: string) { async function setVisibility(userId: string, dataVisibility: DataVisibility) { try { await api.patch(`/users/${userId}/visibility`, { dataVisibility }); - ok.value = 'سطح دید به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.'; + ok.value = + 'سطح دسترسی به پرونده‌ها به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.'; emit('refreshUsers'); } catch (e) { error.value = axios.isAxiosError(e) - ? String((e.response?.data as { message?: string })?.message ?? 'تغییر سطح دید ناموفق بود.') - : 'تغییر سطح دید ناموفق بود.'; + ? String( + (e.response?.data as { message?: string })?.message ?? + 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.', + ) + : 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.'; } } @@ -232,14 +236,14 @@ onMounted(() => {
-

سطح دید داده‌ها

+

سطح دسترسی به پرونده‌ها

مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان.

diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 83b909a..5b2ecea 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -58,7 +58,15 @@ const automationsActive = computed( route.path.startsWith('/settings/sms-campaigns'), ); const toolsActive = computed(() => - pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']), + pathIn([ + '/settings/lead-forms', + '/projects', + '/project-cards', + '/inventory', + '/cheques', + '/attachments', + '/reservations', + ]), ); const settingsActive = computed(() => pathIn([ diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 2fd7404..063dada 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -274,6 +274,7 @@ export const NAV_HUBS: NavHubDef[] = [ shortcuts: [ { to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' }, { to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' }, + { to: '/reservations', title: 'نوبت جدید', icon: 'calendar', module: 'reservations' }, ], cards: [ { @@ -309,6 +310,14 @@ export const NAV_HUBS: NavHubDef[] = [ tint: 'sky', module: 'cheques', }, + { + to: '/reservations', + title: 'رزرو و نوبت‌دهی', + desc: 'خدمات، اسلات آزاد و ثبت نوبت مشتری', + icon: 'calendar', + tint: 'mint', + module: 'reservations', + }, { to: '/attachments', title: 'پیوست‌ها', @@ -353,8 +362,8 @@ export const NAV_HUBS: NavHubDef[] = [ }, { to: '/settings/team#settings-teams', - title: 'تیم‌ها و دیدپذیری', - desc: 'گروه‌های کاری و سطح مشاهده پرونده‌ها', + title: 'تیم‌ها و سطح دسترسی به پرونده‌ها', + desc: 'گروه‌های کاری و سطح دسترسی هر کاربر به پرونده‌ها', icon: 'users', tint: 'sky', module: 'teams', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 5da4e87..1e2ffd8 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -167,6 +167,11 @@ export const router = createRouter({ name: 'cheques', component: () => import('@/views/ChequesView.vue'), }, + { + path: 'reservations', + name: 'reservations', + component: () => import('@/views/ReservationsView.vue'), + }, { path: 'commissions', name: 'commissions', diff --git a/apps/web/src/views/ReservationsView.vue b/apps/web/src/views/ReservationsView.vue new file mode 100644 index 0000000..bd736a0 --- /dev/null +++ b/apps/web/src/views/ReservationsView.vue @@ -0,0 +1,546 @@ + + + + + diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 1f75be2..b1cbac1 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -510,7 +510,7 @@ onUnmounted(() => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f420aac..c2cd6b1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -123,7 +123,8 @@ export type OrgModuleKey = | "sms_campaigns" | "teams" | "woocommerce" - | "voip"; + | "voip" + | "reservations"; export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "customers", @@ -157,6 +158,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "teams", "woocommerce", "voip", + "reservations", ]; export const ORG_MODULE_LABELS: Record = { @@ -188,9 +190,10 @@ export const ORG_MODULE_LABELS: Record = { saved_views: "فیلترهای ذخیره‌شده", public_api: "API و وب‌هوک", sms_campaigns: "پیامک گروهی", - teams: "تیم‌ها و دیدپذیری", + teams: "تیم‌ها و سطح دسترسی به پرونده‌ها", woocommerce: "ووکامرس", voip: "تلفن سازمانی", + reservations: "رزرو و نوبت‌دهی", }; export const ORG_MODULE_GROUPS: Array<{ @@ -232,6 +235,7 @@ export const ORG_MODULE_GROUPS: Array<{ "saved_views", "woocommerce", "voip", + "reservations", ], }, { @@ -3115,7 +3119,7 @@ export interface SmsCampaignListResponse { total: number; } -/* ——— تیم‌ها و دیدپذیری ——— */ +/* ——— تیم‌ها و سطح دسترسی به پرونده‌ها ——— */ export type TeamMemberRole = "member" | "manager"; @@ -3280,3 +3284,96 @@ export interface VoipWebhookEvent { caller?: string; callee?: string; } + +/* ——— رزرو و نوبت‌دهی ——— */ + +export type ReservationStatus = + | "pending" + | "confirmed" + | "canceled" + | "done"; + +export const RESERVATION_STATUSES: ReservationStatus[] = [ + "pending", + "confirmed", + "canceled", + "done", +]; + +export const RESERVATION_STATUS_LABELS: Record = { + pending: "در انتظار", + confirmed: "تأیید شده", + canceled: "لغو شده", + done: "انجام شده", +}; + +export interface ReservationServiceDto { + id: string; + name: string; + description: string; + durationMinutes: number; + /** روزهای هفته ۰=یکشنبه … ۶=شنبه (JS getDay) */ + workDays: number[]; + dayStartHour: number; + dayEndHour: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateReservationServiceRequest { + name: string; + description?: string; + durationMinutes: number; + workDays?: number[]; + dayStartHour?: number; + dayEndHour?: number; + isActive?: boolean; +} + +export type UpdateReservationServiceRequest = + Partial; + +export interface ReservationAppointmentDto { + id: string; + serviceId: string; + serviceName: string; + personId: string; + personName: string; + assigneeId: string | null; + assigneeName: string | null; + startsAt: string; + endsAt: string; + status: ReservationStatus; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateReservationAppointmentRequest { + serviceId: string; + personId: string; + assigneeId?: string | null; + startsAt: string; + status?: ReservationStatus; + notes?: string | null; +} + +export interface UpdateReservationAppointmentRequest { + serviceId?: string; + personId?: string; + assigneeId?: string | null; + startsAt?: string; + status?: ReservationStatus; + notes?: string | null; +} + +export interface ReservationSlotDto { + startsAt: string; + endsAt: string; +} + +export interface ReservationListResponse { + items: ReservationAppointmentDto[]; + total: number; +}