Redesign notes list and editor to match Keep-style mockups.

Add checklist, activity link, location, analyze flag, and audio attachments with a real API-backed masonry UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 19:13:01 +03:30
co-authored by Cursor
parent 32af147237
commit 4e3dbb4e00
7 changed files with 2049 additions and 838 deletions
+75 -2
View File
@@ -6,6 +6,7 @@ import {
import type {
CreateNoteRequest,
NoteAttachmentDto,
NoteChecklistItemDto,
NoteDto,
PaginatedResponse,
UpdateNoteRequest,
@@ -32,11 +33,15 @@ type NoteRow = {
color: string | null;
tags: string[];
reminderAt: Date | null;
checklist: Prisma.JsonValue | null;
locationText: string | null;
analyzeEnabled: boolean;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
activity: { id: string; title: string } | null;
author: { id: string; fullName: string } | null;
attachments: Array<{
id: string;
@@ -51,6 +56,7 @@ const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
activity: { select: { id: true, title: true } },
author: { select: { id: true, fullName: true } },
attachments: {
select: {
@@ -81,6 +87,12 @@ const ALLOWED_MIME = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-zip-compressed',
'audio/webm',
'audio/wav',
'audio/mpeg',
'audio/mp4',
'audio/ogg',
'audio/x-m4a',
]);
@Injectable()
@@ -101,6 +113,7 @@ export class NotesService {
dealId?: string;
tag?: string;
noteType?: string;
sort?: 'updatedAt' | 'createdAt' | 'reminderAt';
page?: number;
limit?: number;
},
@@ -131,11 +144,17 @@ export class NotesService {
: access),
};
const sortField = opts.sort ?? 'updatedAt';
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
{ pinned: 'desc' },
{ [sortField]: 'desc' },
];
const [rows, total] = await this.prisma.$transaction([
this.prisma.note.findMany({
where,
include,
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
orderBy,
skip: (page - 1) * limit,
take: limit,
}),
@@ -174,6 +193,8 @@ export class NotesService {
actor ?? { userId, role: 'owner' },
dto,
);
const analyzeEnabled = dto.analyzeEnabled !== false;
const checklist = parseChecklist(dto.checklist);
const row = await this.prisma.note.create({
data: {
organizationId,
@@ -185,9 +206,15 @@ export class NotesService {
color: emptyToNull(dto.color) ?? 'default',
tags: normalizeTags(dto.tags),
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
checklist: checklist.length
? (checklist as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull,
locationText: emptyToNull(dto.locationText ?? null),
analyzeEnabled,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
activityId: emptyToNull(dto.activityId ?? null),
},
include,
});
@@ -213,9 +240,24 @@ export class NotesService {
if ('reminderAt' in dto) {
data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
}
if (dto.checklist !== undefined) {
const checklist = parseChecklist(dto.checklist);
data.checklist = checklist.length
? (checklist as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull;
}
if ('locationText' in dto) {
data.locationText = emptyToNull(dto.locationText ?? null);
}
if (dto.analyzeEnabled !== undefined) {
data.analyzeEnabled = dto.analyzeEnabled;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('activityId' in dto) {
data.activityId = emptyToNull(dto.activityId ?? null);
}
const row = await this.prisma.note.update({
where: { id },
@@ -308,7 +350,7 @@ export class NotesService {
const mimeType = (file.mimetype || '').toLowerCase();
if (!ALLOWED_MIME.has(mimeType)) {
throw new BadRequestException(
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.',
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن، ZIP یا صوت بفرستید.',
);
}
@@ -404,11 +446,13 @@ export class NotesService {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
activityId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const activityId = emptyToNull(dto.activityId ?? null);
const scope = ownerWhere(actor);
if (personId) {
@@ -432,6 +476,13 @@ export class NotesService {
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
if (activityId) {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: { id: true },
});
if (!activity) throw new BadRequestException('فعالیت انتخاب‌شده معتبر نیست');
}
}
private toDto(row: NoteRow): NoteDto {
@@ -444,6 +495,9 @@ export class NotesService {
color: row.color,
tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null,
checklist: parseChecklist(row.checklist),
locationText: row.locationText ?? null,
analyzeEnabled: row.analyzeEnabled ?? true,
person: row.person
? {
id: row.person.id,
@@ -452,6 +506,7 @@ export class NotesService {
: null,
company: row.company,
deal: row.deal,
activity: row.activity,
author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: row.createdAt.toISOString(),
@@ -500,3 +555,21 @@ function sanitizeFileName(raw: string): string {
const trimmed = base.trim() || 'file';
return trimmed.slice(0, 180);
}
function parseChecklist(raw: unknown): NoteChecklistItemDto[] {
if (!Array.isArray(raw)) return [];
const items: NoteChecklistItemDto[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const rec = entry as Record<string, unknown>;
const id = typeof rec.id === 'string' ? rec.id.trim() : '';
const text = typeof rec.text === 'string' ? rec.text.trim() : '';
if (!id && !text) continue;
items.push({
id: id || randomUUID(),
text: text.slice(0, 500),
done: Boolean(rec.done),
});
}
return items.slice(0, 50);
}