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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user