feat(activities): attach compressed photos to activity notes
Photos on an activity are shrunk with the form-builder compressor and stored only when they fit under 200KB. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityImage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"activityId" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"uploadedById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -174,6 +174,7 @@ model Organization {
|
|||||||
products Product[]
|
products Product[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
noteAttachments NoteAttachment[]
|
noteAttachments NoteAttachment[]
|
||||||
|
activityImages ActivityImage[]
|
||||||
leadForms LeadForm[]
|
leadForms LeadForm[]
|
||||||
quotes Quote[]
|
quotes Quote[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
@@ -293,6 +294,7 @@ model User {
|
|||||||
notes Note[] @relation("NoteAuthor")
|
notes Note[] @relation("NoteAuthor")
|
||||||
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
||||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||||
|
activityImages ActivityImage[] @relation("ActivityImageUploader")
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||||
@@ -866,6 +868,7 @@ model Activity {
|
|||||||
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||||
leadId String?
|
leadId String?
|
||||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||||
|
images ActivityImage[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -884,6 +887,26 @@ model Activity {
|
|||||||
@@index([dealId])
|
@@index([dealId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// عکس فشردهشدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
|
||||||
|
model ActivityImage {
|
||||||
|
id String @id
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
activityId String
|
||||||
|
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
||||||
|
fileName String
|
||||||
|
mimeType String
|
||||||
|
sizeBytes Int
|
||||||
|
storageKey String
|
||||||
|
uploadedById String?
|
||||||
|
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([activityId, createdAt])
|
||||||
|
@@index([organizationId])
|
||||||
|
@@index([uploadedById])
|
||||||
|
}
|
||||||
|
|
||||||
model Note {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
@@ -309,6 +309,56 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.get(user.organizationId, id, user);
|
return this.activitiesService.get(user.organizationId, id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/images')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
addImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.activitiesService.addImage(
|
||||||
|
user.organizationId,
|
||||||
|
user.userId,
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/images/:imageId')
|
||||||
|
async downloadImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('imageId') imageId: string,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
|
||||||
|
user.organizationId,
|
||||||
|
id,
|
||||||
|
imageId,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
const encoded = encodeURIComponent(fileName);
|
||||||
|
return new StreamableFile(stream, {
|
||||||
|
type: mimeType,
|
||||||
|
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/images/:imageId')
|
||||||
|
async removeImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('imageId') imageId: string,
|
||||||
|
) {
|
||||||
|
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/promote-from-prior')
|
@Post(':id/promote-from-prior')
|
||||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
return this.activitiesService.promoteFromPrior(user, id);
|
return this.activitiesService.promoteFromPrior(user, id);
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
||||||
controllers: [ActivitiesController],
|
controllers: [ActivitiesController],
|
||||||
providers: [
|
providers: [
|
||||||
ActivitiesService,
|
ActivitiesService,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ActivityDto,
|
ActivityDto,
|
||||||
ActivityFieldValues,
|
ActivityFieldValues,
|
||||||
ActivityFilter,
|
ActivityFilter,
|
||||||
|
ActivityImageDto,
|
||||||
ActivitySummaryDto,
|
ActivitySummaryDto,
|
||||||
ActivityStageCountsDto,
|
ActivityStageCountsDto,
|
||||||
CreateActivityRequest,
|
CreateActivityRequest,
|
||||||
@@ -15,6 +16,9 @@ import type {
|
|||||||
UpdateActivityRequest,
|
UpdateActivityRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
@@ -84,8 +88,19 @@ type ActivityRow = {
|
|||||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||||
fields?: Prisma.JsonValue | null;
|
fields?: Prisma.JsonValue | null;
|
||||||
participantIds?: string[];
|
participantIds?: string[];
|
||||||
|
images?: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
|
||||||
|
const ACTIVITY_IMAGE_MAX_COUNT = 8;
|
||||||
|
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
|
|
||||||
const include = {
|
const include = {
|
||||||
stage: { select: { id: true, kind: true, boardId: true } },
|
stage: { select: { id: true, kind: true, boardId: true } },
|
||||||
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
||||||
@@ -108,6 +123,7 @@ export class ActivitiesService {
|
|||||||
private readonly stages: ActivityStagesService,
|
private readonly stages: ActivityStagesService,
|
||||||
private readonly types: ActivityTypesService,
|
private readonly types: ActivityTypesService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
|
private readonly files: LocalFileStorage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||||
@@ -382,7 +398,19 @@ export class ActivitiesService {
|
|||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
const row = await this.prisma.activity.findFirst({
|
const row = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||||
include,
|
include: {
|
||||||
|
...include,
|
||||||
|
images: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
sizeBytes: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||||
@@ -701,7 +729,102 @@ export class ActivitiesService {
|
|||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
|
const images = await this.prisma.activityImage.findMany({
|
||||||
|
where: { activityId: id, organizationId },
|
||||||
|
select: { storageKey: true },
|
||||||
|
});
|
||||||
await this.prisma.activity.delete({ where: { id } });
|
await this.prisma.activity.delete({ where: { id } });
|
||||||
|
for (const image of images) {
|
||||||
|
await this.files.remove(image.storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addImage(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
activityId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<ActivityImageDto> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایلی ارسال نشده است');
|
||||||
|
}
|
||||||
|
const mimeType = (file.mimetype || '').toLowerCase();
|
||||||
|
if (!ACTIVITY_IMAGE_MIME.has(mimeType)) {
|
||||||
|
throw new BadRequestException('فقط عکس میتوانید اضافه کنید.');
|
||||||
|
}
|
||||||
|
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
|
||||||
|
}
|
||||||
|
const count = await this.prisma.activityImage.count({ where: { activityId } });
|
||||||
|
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
|
||||||
|
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
|
||||||
|
}
|
||||||
|
const fileName = sanitizeActivityImageName(file.originalname);
|
||||||
|
const ext = path.extname(fileName).slice(0, 12) || '.jpg';
|
||||||
|
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||||
|
const storageKey = path
|
||||||
|
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
||||||
|
.replace(/\\/g, '/');
|
||||||
|
await this.files.save(storageKey, file.buffer);
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.activityImage.create({
|
||||||
|
data: {
|
||||||
|
id: imageId,
|
||||||
|
organizationId,
|
||||||
|
activityId,
|
||||||
|
fileName,
|
||||||
|
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
storageKey,
|
||||||
|
uploadedById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await this.files.remove(storageKey);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeImage(
|
||||||
|
organizationId: string,
|
||||||
|
activityId: string,
|
||||||
|
imageId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
const row = await this.prisma.activityImage.findFirst({
|
||||||
|
where: { id: imageId, activityId, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
||||||
|
await this.files.remove(row.storageKey);
|
||||||
|
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async openImage(
|
||||||
|
organizationId: string,
|
||||||
|
activityId: string,
|
||||||
|
imageId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
const row = await this.prisma.activityImage.findFirst({
|
||||||
|
where: { id: imageId, activityId, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
||||||
|
return {
|
||||||
|
stream: this.files.openReadStream(row.storageKey),
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildWhere(
|
private buildWhere(
|
||||||
@@ -971,6 +1094,13 @@ export class ActivitiesService {
|
|||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
images: row.images?.map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
fileName: image.fileName,
|
||||||
|
mimeType: image.mimeType,
|
||||||
|
sizeBytes: image.sizeBytes,
|
||||||
|
createdAt: image.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
dueAt: row.dueAt?.toISOString() ?? null,
|
dueAt: row.dueAt?.toISOString() ?? null,
|
||||||
durationMinutes: row.durationMinutes,
|
durationMinutes: row.durationMinutes,
|
||||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||||
@@ -1042,6 +1172,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeActivityImageName(raw: string): string {
|
||||||
|
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||||
|
const trimmed = base.trim() || 'photo.jpg';
|
||||||
|
return trimmed.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyToNull(value?: string | null): string | null {
|
function emptyToNull(value?: string | null): string | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ describe('promote from prior', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
|
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dto = await service.promoteFromPrior(actor, 'act1');
|
const dto = await service.promoteFromPrior(actor, 'act1');
|
||||||
@@ -191,6 +192,7 @@ describe('promote from prior', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
|
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'act1'), ForbiddenException);
|
await assert.rejects(service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'act1'), ForbiddenException);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export async function prepareFormFieldFile(file: File): Promise<{
|
|||||||
mime: string;
|
mime: string;
|
||||||
size: number;
|
size: number;
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
|
file: File;
|
||||||
}> {
|
}> {
|
||||||
let prepared = file;
|
let prepared = file;
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
@@ -115,6 +116,7 @@ export async function prepareFormFieldFile(file: File): Promise<{
|
|||||||
mime: prepared.type || file.type || 'application/octet-stream',
|
mime: prepared.type || file.type || 'application/octet-stream',
|
||||||
size: prepared.size,
|
size: prepared.size,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
|
file: prepared,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ const loading = ref(true);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const activity = ref<ActivityDto | null>(null);
|
const activity = ref<ActivityDto | null>(null);
|
||||||
|
const imageUrls = ref<Array<{ id: string; name: string; url: string }>>([]);
|
||||||
const moreOpen = ref(false);
|
const moreOpen = ref(false);
|
||||||
const commentOpen = ref(false);
|
const commentOpen = ref(false);
|
||||||
const postponeOpen = ref(false);
|
const postponeOpen = ref(false);
|
||||||
@@ -130,6 +131,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||||
activity.value = data;
|
activity.value = data;
|
||||||
|
await loadActivityImages(data);
|
||||||
assignUserIds.value = data.assignees?.length
|
assignUserIds.value = data.assignees?.length
|
||||||
? data.assignees.map((u) => u.id)
|
? data.assignees.map((u) => u.id)
|
||||||
: data.assignee?.id
|
: data.assignee?.id
|
||||||
@@ -143,6 +145,29 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearImageUrls() {
|
||||||
|
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
|
||||||
|
imageUrls.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActivityImages(row: ActivityDto) {
|
||||||
|
clearImageUrls();
|
||||||
|
const next: Array<{ id: string; name: string; url: string }> = [];
|
||||||
|
for (const image of row.images ?? []) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
next.push({ id: image.id, name: image.fileName, url: URL.createObjectURL(data) });
|
||||||
|
} catch {
|
||||||
|
/* عکس در دسترس نیست */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageUrls.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(clearImageUrls);
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!canReassign.value) return;
|
if (!canReassign.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -446,9 +471,15 @@ onMounted(() => {
|
|||||||
</strong>
|
</strong>
|
||||||
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
||||||
</button>
|
</button>
|
||||||
<p v-show="noteOpen" :class="{ empty: !activity.description?.trim() }">
|
<div v-show="noteOpen">
|
||||||
{{ activity.description?.trim() || 'یادداشتی ثبت نشده.' }}
|
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
|
||||||
</p>
|
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
||||||
|
<div v-if="imageUrls.length" class="note-photos">
|
||||||
|
<a v-for="image in imageUrls" :key="image.id" :href="image.url" target="_blank" rel="noopener">
|
||||||
|
<img :src="image.url" :alt="image.name" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -800,6 +831,26 @@ dd {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-photos a {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-photos img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.dock {
|
.dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 0.85rem;
|
inset-inline: 0.85rem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||||
import { appendSpokenText } from '@/lib/spoken-text';
|
import { appendSpokenText } from '@/lib/spoken-text';
|
||||||
|
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
import UserAvatar from '@/components/UserAvatar.vue';
|
import UserAvatar from '@/components/UserAvatar.vue';
|
||||||
@@ -33,6 +34,7 @@ import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
|||||||
import type {
|
import type {
|
||||||
ActivityBoardDto,
|
ActivityBoardDto,
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
|
ActivityImageDto,
|
||||||
ActivityFieldValue,
|
ActivityFieldValue,
|
||||||
ActivityFieldValues,
|
ActivityFieldValues,
|
||||||
ActivityPriority,
|
ActivityPriority,
|
||||||
@@ -92,6 +94,16 @@ const keepTypeId = ref('');
|
|||||||
const priority = ref<ActivityPriority>('medium');
|
const priority = ref<ActivityPriority>('medium');
|
||||||
const description = ref('');
|
const description = ref('');
|
||||||
const tagsRaw = ref('');
|
const tagsRaw = ref('');
|
||||||
|
const PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد';
|
||||||
|
const PHOTO_LIMIT = 8;
|
||||||
|
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
|
||||||
|
type SavedPhoto = ActivityImageDto & { previewUrl: string };
|
||||||
|
const pendingPhotos = ref<PendingPhoto[]>([]);
|
||||||
|
const savedPhotos = ref<SavedPhoto[]>([]);
|
||||||
|
const photoBusy = ref(false);
|
||||||
|
const photoError = ref('');
|
||||||
|
const createdActivityId = ref('');
|
||||||
|
const photoInput = ref<HTMLInputElement | null>(null);
|
||||||
const dueDate = ref('');
|
const dueDate = ref('');
|
||||||
const dueTime = ref('10:30');
|
const dueTime = ref('10:30');
|
||||||
const durationMinutes = ref(30);
|
const durationMinutes = ref(30);
|
||||||
@@ -370,6 +382,8 @@ function resetForm() {
|
|||||||
priority.value = 'medium';
|
priority.value = 'medium';
|
||||||
description.value = '';
|
description.value = '';
|
||||||
tagsRaw.value = '';
|
tagsRaw.value = '';
|
||||||
|
clearPhotos();
|
||||||
|
createdActivityId.value = '';
|
||||||
defaultDue();
|
defaultDue();
|
||||||
durationMinutes.value = 30;
|
durationMinutes.value = 30;
|
||||||
personId.value = '';
|
personId.value = '';
|
||||||
@@ -461,6 +475,7 @@ async function load() {
|
|||||||
: [];
|
: [];
|
||||||
refreshPickedRoles();
|
refreshPickedRoles();
|
||||||
isDone.value = data.status === 'done';
|
isDone.value = data.status === 'done';
|
||||||
|
await loadSavedPhotos(data);
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
const incomingPerson =
|
const incomingPerson =
|
||||||
@@ -581,6 +596,99 @@ function toggleRole(roleId: string, userIds: string[]) {
|
|||||||
refreshPickedRoles();
|
refreshPickedRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPhotos() {
|
||||||
|
pendingPhotos.value = [];
|
||||||
|
for (const photo of savedPhotos.value) {
|
||||||
|
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
|
||||||
|
}
|
||||||
|
savedPhotos.value = [];
|
||||||
|
photoError.value = '';
|
||||||
|
photoBusy.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSavedPhotos(activity: ActivityDto) {
|
||||||
|
clearPhotos();
|
||||||
|
const images = activity.images ?? [];
|
||||||
|
const loaded: SavedPhoto[] = [];
|
||||||
|
for (const image of images) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Blob>(`/activities/${activity.id}/images/${image.id}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
loaded.push({ ...image, previewUrl: URL.createObjectURL(data) });
|
||||||
|
} catch {
|
||||||
|
loaded.push({ ...image, previewUrl: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savedPhotos.value = loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPhotosPicked(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
if (!files.length) return;
|
||||||
|
photoError.value = '';
|
||||||
|
for (const file of files) {
|
||||||
|
if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) {
|
||||||
|
photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
photoError.value = 'فقط عکس میتوانید اضافه کنید.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
photoBusy.value = true;
|
||||||
|
try {
|
||||||
|
const prepared = await prepareFormFieldFile(file);
|
||||||
|
pendingPhotos.value = [
|
||||||
|
...pendingPhotos.value,
|
||||||
|
{
|
||||||
|
localId: crypto.randomUUID(),
|
||||||
|
name: prepared.name,
|
||||||
|
dataUrl: prepared.dataUrl,
|
||||||
|
file: prepared.file,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
photoError.value =
|
||||||
|
e instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشردهسازی عکس ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
photoBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingPhoto(localId: string) {
|
||||||
|
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
|
||||||
|
if (photoError.value === PHOTO_TOO_LARGE) photoError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSavedPhoto(photo: SavedPhoto) {
|
||||||
|
const activityId = id.value || createdActivityId.value;
|
||||||
|
if (!activityId) return;
|
||||||
|
await api.delete(`/activities/${activityId}/images/${photo.id}`);
|
||||||
|
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
|
||||||
|
savedPhotos.value = savedPhotos.value.filter((item) => item.id !== photo.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPendingPhotos(activityId: string) {
|
||||||
|
const queue = [...pendingPhotos.value];
|
||||||
|
for (const photo of queue) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', photo.file, photo.name);
|
||||||
|
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
|
||||||
|
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== photo.localId);
|
||||||
|
savedPhotos.value = [...savedPhotos.value, { ...data, previewUrl: photo.dataUrl }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const photo of savedPhotos.value) {
|
||||||
|
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
||||||
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
||||||
@@ -590,7 +698,14 @@ function validate() {
|
|||||||
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
|
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
|
||||||
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
||||||
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
||||||
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
if (
|
||||||
|
needsNote.value &&
|
||||||
|
!description.value.trim() &&
|
||||||
|
!pendingPhotos.value.length &&
|
||||||
|
!savedPhotos.value.length
|
||||||
|
) {
|
||||||
|
return 'توضیح این فعالیت را بنویسید.';
|
||||||
|
}
|
||||||
if (reminderAt.value && reminderChanged.value) {
|
if (reminderAt.value && reminderChanged.value) {
|
||||||
const when = parseLocalInput(reminderAt.value);
|
const when = parseLocalInput(reminderAt.value);
|
||||||
if (when && when.getTime() < Date.now() - 60_000) {
|
if (when && when.getTime() < Date.now() - 60_000) {
|
||||||
@@ -613,22 +728,24 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
|||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
let savedId = id.value;
|
let savedId = id.value || createdActivityId.value;
|
||||||
if (isNew.value) {
|
if (!savedId) {
|
||||||
const { data } = await api.post<ActivityDto>('/activities', {
|
const { data } = await api.post<ActivityDto>('/activities', {
|
||||||
...payload(),
|
...payload(),
|
||||||
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
|
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
|
||||||
});
|
});
|
||||||
savedId = data.id;
|
savedId = data.id;
|
||||||
|
createdActivityId.value = data.id;
|
||||||
if (isDone.value) {
|
if (isDone.value) {
|
||||||
await api.patch(`/activities/${savedId}/complete`);
|
await api.patch(`/activities/${savedId}/complete`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await api.patch(`/activities/${id.value}`, {
|
await api.patch(`/activities/${savedId}`, {
|
||||||
...payload(),
|
...payload(),
|
||||||
status: isDone.value ? 'done' : 'open',
|
status: isDone.value ? 'done' : 'open',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await uploadPendingPhotos(savedId);
|
||||||
if (props.embedded) {
|
if (props.embedded) {
|
||||||
emit('saved');
|
emit('saved');
|
||||||
return;
|
return;
|
||||||
@@ -1062,6 +1179,32 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
:rows="4"
|
:rows="4"
|
||||||
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
||||||
/>
|
/>
|
||||||
|
<div class="photo-row">
|
||||||
|
<button type="button" class="photo-add" :disabled="photoBusy" @click="photoInput?.click()">
|
||||||
|
<UiIcon name="camera" :size="16" />
|
||||||
|
{{ photoBusy ? 'در حال کمحجم کردن عکس…' : 'افزودن عکس' }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="photoInput"
|
||||||
|
class="photo-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="onPhotosPicked"
|
||||||
|
/>
|
||||||
|
<small class="hint">عکس خودکار کمحجم میشود تا زیر ۲۰۰ کیلوبایت بماند.</small>
|
||||||
|
</div>
|
||||||
|
<p v-if="photoError" class="error">{{ photoError }}</p>
|
||||||
|
<div v-if="pendingPhotos.length || savedPhotos.length" class="photo-grid">
|
||||||
|
<figure v-for="photo in savedPhotos" :key="photo.id">
|
||||||
|
<img v-if="photo.previewUrl" :src="photo.previewUrl" :alt="photo.fileName" />
|
||||||
|
<button type="button" aria-label="حذف عکس" @click="removeSavedPhoto(photo)">×</button>
|
||||||
|
</figure>
|
||||||
|
<figure v-for="photo in pendingPhotos" :key="photo.localId">
|
||||||
|
<img :src="photo.dataUrl" :alt="photo.name" />
|
||||||
|
<button type="button" aria-label="حذف عکس" @click="removePendingPhoto(photo.localId)">×</button>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
|
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
|
||||||
<UiInput
|
<UiInput
|
||||||
id="activity-tags"
|
id="activity-tags"
|
||||||
@@ -1900,6 +2043,71 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px dashed #99f6e4;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: #0f766e;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-add:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-file {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid figure {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid figure button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.15rem;
|
||||||
|
inset-inline-end: 0.15rem;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.muted,
|
.muted,
|
||||||
.error {
|
.error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1819,6 +1819,14 @@ export interface DealSummaryDto {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityImageDto {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityDto {
|
export interface ActivityDto {
|
||||||
id: string;
|
id: string;
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
@@ -1826,6 +1834,8 @@ export interface ActivityDto {
|
|||||||
priority: ActivityPriority;
|
priority: ActivityPriority;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
/** عکسهای یادداشت؛ فقط در جزئیات فعالیت پر میشود */
|
||||||
|
images?: ActivityImageDto[];
|
||||||
dueAt: string | null;
|
dueAt: string | null;
|
||||||
durationMinutes: number | null;
|
durationMinutes: number | null;
|
||||||
reminderAt: string | null;
|
reminderAt: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user