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:
2026-10-27 21:30:45 +03:30
co-authored by Cursor
parent 9812e4e5ba
commit 1d3588a69d
10 changed files with 526 additions and 11 deletions
@@ -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;
+23
View File
@@ -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);
+2 -1
View File
@@ -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,
+137 -1
View File
@@ -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();
+2
View File
@@ -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);
+2
View File
@@ -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,
}; };
} }
+55 -4
View File
@@ -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;
+213 -5
View File
@@ -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;
+10
View File
@@ -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;