task-ep.1.2

This commit is contained in:
phaichayon
2026-07-13 09:49:45 +07:00
parent 9ae4f31f28
commit e96320a24d
28 changed files with 1337 additions and 21 deletions

View File

@@ -0,0 +1,151 @@
# Task EP.1.2 Activity Integration & Legacy Follow-up Adapter - 2026-07-13
## Scope
- introduced adapter-based Activity timeline integration for legacy lead, opportunity, and quotation follow-ups
- added additive `recentActivities` detail API data for customer, lead, opportunity, and quotation workspaces
- kept existing follow-up APIs, dashboard consumers, report datasets, and audit history unchanged
- added Activity numbering through the Document Sequence Foundation using `documentType = activity`
- added Activity attachment foundation service on top of Storage and Document Artifact foundations
- documented migration readiness without executing migration, dual-write, projections, calendar, timeline, notifications, or dashboard swaps
## Review Summary
Reviewed before and during implementation:
- `AGENTS.md`
- `plans/task-ep.1.2.md`
- `docs/standards/task-catalog.md`
- `docs/standards/project-foundations.md`
- `docs/standards/architecture-rules.md`
- `docs/standards/ui-ux-rules.md`
- `docs/standards/task-review-checklist.md`
- `docs/security/crm-authorization-boundaries.md`
- `docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md`
- `docs/implementation/task-ar.1-architecture-transition-plan-2026-07-07.md`
- `docs/implementation/task-ar.2-epic-technical-design-2026-07-07.md`
- `docs/implementation/task-ar.2-workspace-ui-ux-design-note-2026-07-07.md`
- existing lead, opportunity, quotation follow-up services and APIs
- existing dashboard and report follow-up consumers
- existing audit, storage, document artifact, and document sequence foundations
## Implementation Summary
### Legacy Follow-up Adapters
Created Activity-compatible timeline adapters:
- `src/features/crm/activities/server/adapters/lead-followup.adapter.ts`
- `src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts`
- `src/features/crm/activities/server/adapters/quotation-followup.adapter.ts`
The adapters only read and translate legacy records. They do not migrate data, dual-write, or remove existing follow-up behavior.
### Compatibility Layer
Added `src/features/crm/activities/server/timeline-service.ts` as the single Activity compatibility read layer for detail-page recent activity views.
Current composition:
- new `crm_activities` rows queried by context columns such as `customerId`, `leadId`, `opportunityId`, and `quotationId`
- legacy lead follow-ups adapted from audit-log-backed records
- legacy opportunity follow-ups adapted from row-backed records
- legacy quotation follow-ups adapted from row-backed records
Dashboard and report datasets remain on their existing legacy sources for EP.1.2.
### Activity Context Integration
Integrated recent activity reads into:
- customer detail API and UI
- lead detail API and UI
- opportunity detail API and UI
- quotation detail API and UI
The UI uses `RecentActivitiesPanel` and keeps existing follow-up tabs and audit log tabs in place.
### Activity Type Governance
Expanded the official fallback Activity catalog to cover the EP.1.2 minimum types and kept backward-compatible aliases.
Reference data now prefers `crm_activity_type` master options when configured, with static catalog fallback for environments that have not seeded the new category yet.
### Activity Numbering Strategy
Activity creation now calls `generateNextDocumentCode()` from the Document Sequence Foundation using:
- `documentType = activity`
- branch scope from the resolved Activity context
- product type scope from the resolved Activity context
- default prefix `ACT`
- period-based sequence from the existing foundation
The generated code is stored in `crm_activities.metadata.activityCode` to avoid a schema migration in EP.1.2.
### Activity Attachment Foundation
Added `src/features/crm/activities/server/attachment-service.ts`.
The service supports Activity attachment metadata and binary storage through:
- Storage Foundation object writes
- Document Artifact Foundation metadata records
- `entityType = crm_activity`
- `artifactType = attachment`
Supported attachment kinds are `image`, `pdf`, `office_document`, `voice_recording`, `drawing`, and `other`.
No separate storage engine or attachment table was introduced.
## Adapter Validation
| Source | Legacy Storage | Adapter Output | Validation Status |
| --- | --- | --- | --- |
| Lead follow-up | audit log payload | `ActivityTimelineItem` | implemented |
| Opportunity follow-up | `crm_opportunity_followups` | `ActivityTimelineItem` | implemented |
| Quotation follow-up | `crm_quotation_followups` | `ActivityTimelineItem` | implemented |
| New Activity | `crm_activities` | `ActivityTimelineItem` | implemented |
## Legacy Usage Inventory
Existing consumers intentionally remain unchanged:
- lead follow-up API and panel
- opportunity follow-up API and tab
- quotation follow-up API and tab
- CRM dashboard service follow-up queries
- pipeline/report datasets reading legacy follow-up state
- audit log views for customer, lead, opportunity, and quotation details
## Projection Readiness
EP.1.3 can consume the following stable contracts:
- `ActivityTimelineItem`
- legacy follow-up adapters
- `listRecentActivitiesForContext()`
- Activity numbering metadata under `metadata.activityCode`
- Activity attachment artifacts under `entityType = crm_activity`
Projection consumers should still wait for EP.1.3 event and projection contracts before replacing dashboard, report, calendar, or timeline behavior.
## Migration Checklist
- Keep legacy follow-up writes active until adapter parity is validated with production-like data.
- Backfill or project legacy follow-ups only after a separate migration task approves the source-of-truth switch.
- Decide whether `metadata.activityCode` should become a first-class column in a future schema task.
- Add upload/download route handlers for Activity attachments when the UI workflow is scheduled.
- Add seeded `crm_activity_type` master options before disabling fallback catalog behavior.
- Run dataset regression checks before dashboard/report consumers switch from legacy follow-ups to Activity projections.
## Verification
- `npm run typecheck`
- targeted `npx oxlint` for Activity adapter, timeline, route, and detail UI files
## Residual Risks
- Activity attachment upload/download routes are intentionally deferred; the foundation service is ready for those routes.
- Activity numbering is metadata-backed in EP.1.2 to honor the no-migration constraint.
- Detail pages show recent activity read integration, but create-activity shortcuts from each detail page are still deferred to the next UI workflow task.

382
plans/task-ep.1.2.md Normal file
View File

@@ -0,0 +1,382 @@
# EP.1.2 Activity Integration & Legacy Follow-up Adapter
Status: Completed
Priority: Critical
Type: Feature Integration
Depends On
- EP.1.1 Activity Domain Foundation
- ENG.0 Engineering Constitution
- AR.1 Architecture Transition Plan
- AR.2 Epic Technical Design
---
# Objective
Introduce the Activity Integration Layer that connects the new Activity Domain with existing CRM modules while preserving all legacy follow-up implementations.
This phase establishes adapter-based integration without introducing data migration, dual-write, or projection consumers.
The objective is to prepare existing modules to consume Activity incrementally while maintaining production stability.
---
# Background
EP.1.1 introduced the additive `crm_activities` write model.
Current CRM still contains multiple follow-up implementations:
- Lead (audit-log-backed)
- Opportunity (row-backed)
- Quotation (row-backed)
Business Constitution defines Activity as the long-term operational model.
Architecture Constitution requires:
- Preserve existing production behavior.
- Introduce Activity through adapters.
- Delay consolidation until adapters are validated.
---
# Review Required
Review
Business
- Relationship & Sales Workspace Blueprint
- Activity Blueprint
Architecture
- AR.1 Architecture Transition Plan
- AR.2 Epic Technical Design
Engineering
- Engineering Constitution
Implementation
- EP.1.1 Activity Foundation
- Existing Lead Follow-up
- Existing Opportunity Follow-up
- Existing Quotation Follow-up
- Existing Dashboard queries
- Existing Reports
- Existing Audit Log
UI
- Workspace UI/UX Design Note
- layout.md
- ui-ux-rules.md
- ui-ux-pro-max
---
# Scope
## Part 1 — Legacy Follow-up Adapter Layer
Introduce adapter interfaces.
Create
LeadFollowUpAdapter
OpportunityFollowUpAdapter
QuotationFollowUpAdapter
Responsibilities
Read legacy follow-up
Translate legacy model
Produce Activity-compatible DTO
No database migration.
No dual write.
---
## Part 2 — Activity Context Integration
Integrate Activity with
Customer
Contact
Lead
Opportunity
Quotation
PO
Activity must resolve context without changing ownership of those domains.
---
## Part 3 — Activity Type Governance
Freeze the official activity catalog.
Minimum types
- Meeting
- Visit
- Phone Call
- Email
- Site Survey
- Presentation
- Reminder
- Internal Task
- Follow-up
- Delivery Coordination
- Approval Action
Introduce `crm_activity_type` master options.
No hard-coded types.
---
## Part 4 — Activity Context Strategy
Freeze
contextType
contextId
supported contexts
Exactly one primary context.
Optional related references.
Prepare Timeline and Calendar compatibility.
---
## Part 5 — Activity Number Strategy
Introduce configurable document numbering.
Example
ACT2607-0001
Support
Organization
Branch
Period
Sequence
Reuse existing Document Sequence Foundation.
---
## Part 6 — Activity Attachment Foundation
Integrate Activity with existing Storage Foundation.
Support
Images
PDF
Office Documents
Voice Recording
Drawing
No new storage engine.
Reuse current abstraction.
---
## Part 7 — Activity UI Integration
Extend existing pages.
Customer Detail
Opportunity Detail
Quotation Detail
Lead Detail
Add
Recent Activities
Create Activity
Activity Summary
Do NOT replace Follow-up UI.
Run both in parallel.
---
## Part 8 — Permission Integration
Reuse existing CRM permission model.
Support
Owner
Assignee
Manager
Administrator
Organization
Branch
Product Type
Pricing visibility
Internal-only Activity
---
## Part 9 — Compatibility Layer
Introduce compatibility interfaces.
Legacy Follow-up
Activity Adapter
Activity DTO
Current consumers remain unchanged.
No existing API removed.
No schema migration.
---
## Part 10 — Migration Readiness
Produce
Adapter validation
Compatibility report
Legacy usage inventory
Projection readiness report
Migration checklist
No migration execution.
---
# Deliverables
1. Lead Follow-up Adapter
2. Opportunity Follow-up Adapter
3. Quotation Follow-up Adapter
4. Activity Context Integration
5. Activity Type Governance
6. Activity Numbering Strategy
7. Activity Attachment Foundation
8. Activity UI Integration
9. Compatibility Layer
10. Migration Readiness Report
---
# Constraints
Must preserve
- Existing Follow-up
- Existing APIs
- Existing Reports
- Existing Dashboard
- Existing Audit History
- Existing Permissions
No Timeline.
No Calendar.
No Notification.
No My Day.
No Dashboard Projection.
No Event Publishing.
No data migration.
No dual write.
---
# Acceptance Criteria
- Activity integrates with all major CRM modules.
- Existing Follow-up continues to function.
- Adapter Layer becomes the only integration point.
- Activity Types are centrally governed.
- Activity Numbering follows Document Sequence Foundation.
- Activity Attachments reuse existing Storage Foundation.
- No production behavior changes.
- No breaking API changes.
- No duplicated business logic.
- No duplicated lifecycle.
---
# Success Criteria
Activity is fully integrated into the CRM foundation.
Legacy Follow-up remains operational.
All future projection epics can consume Activity through stable interfaces.
Repository remains backward compatible.
Ready for
EP.1.3 Business Event Foundation & Projection Contracts.

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
@@ -38,10 +39,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membership
});
const [customer, activity, ownerHistory] = await Promise.all([
const [customer, activity, ownerHistory, recentActivities] = await Promise.all([
getCustomerDetail(id, organization.id, accessContext),
getCustomerActivity(id, organization.id),
getCustomerOwnerHistory(id, organization.id, accessContext)
getCustomerOwnerHistory(id, organization.id, accessContext),
listRecentActivitiesForContext({
organizationId: organization.id,
entityType: 'customer',
entityId: id,
context: accessContext,
limit: 6
})
]);
return NextResponse.json({
@@ -50,6 +58,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
message: 'Customer loaded successfully',
customer,
activity,
recentActivities,
ownerHistory
});
} catch (error) {

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { updateLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
import {
@@ -28,10 +29,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membership
});
const [lead, followups, referenceData] = await Promise.all([
const [lead, followups, referenceData, recentActivities] = await Promise.all([
getLeadById(id, organization.id, accessContext),
listLeadFollowups(id, organization.id, accessContext),
getLeadReferenceData(organization.id)
getLeadReferenceData(organization.id),
listRecentActivitiesForContext({
organizationId: organization.id,
entityType: 'lead',
entityId: id,
context: accessContext,
limit: 6
})
]);
await auditAction({
@@ -52,7 +60,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
message: 'Lead loaded successfully',
lead,
followups,
referenceData
referenceData,
recentActivities
});
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getOpportunityActivity,
@@ -42,9 +43,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const [opportunity, activity] = await Promise.all([
const [opportunity, activity, recentActivities] = await Promise.all([
getOpportunityDetail(id, organization.id, accessContext),
getOpportunityActivity(id, organization.id, accessContext)
getOpportunityActivity(id, organization.id, accessContext),
listRecentActivitiesForContext({
organizationId: organization.id,
entityType: 'opportunity',
entityId: id,
context: accessContext,
limit: 6
})
]);
return NextResponse.json({
@@ -52,7 +60,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
time: new Date().toISOString(),
message: 'Opportunity loaded successfully',
opportunity,
activity
activity,
recentActivities
});
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import {
@@ -27,9 +28,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membership
});
const [quotation, activity] = await Promise.all([
const [quotation, activity, recentActivities] = await Promise.all([
getQuotationDetail(id, organization.id, accessContext),
getQuotationActivity(id, organization.id)
getQuotationActivity(id, organization.id),
listRecentActivitiesForContext({
organizationId: organization.id,
entityType: 'quotation',
entityId: id,
context: accessContext,
limit: 6
})
]);
return NextResponse.json({
@@ -37,7 +45,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
time: new Date().toISOString(),
message: 'Quotation loaded successfully',
quotation,
activity
activity,
recentActivities
});
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -21,6 +21,7 @@ export interface ActivityAssignableUser {
}
export interface ActivityRecord extends CrmActivityRecord {
activityCode: string | null;
effectiveStatus: CrmActivityStatus;
ownerName: string | null;
assignedToName: string | null;
@@ -30,6 +31,37 @@ export interface ActivityRecord extends CrmActivityRecord {
isContentRedacted: boolean;
}
export type ActivityTimelineItemSource =
| 'activity'
| 'lead_followup'
| 'opportunity_followup'
| 'quotation_followup';
export interface ActivityTimelineItem {
id: string;
source: ActivityTimelineItemSource;
sourceLabel: string;
isLegacy: boolean;
activityId: string | null;
activityCode: string | null;
primaryEntityType: CrmActivityEntityType;
primaryEntityId: string | null;
primaryRecordLabel: string | null;
activityType: string;
activityTypeLabel: string;
subject: string;
description: string | null;
status: string;
statusLabel: string;
occurredAt: string;
scheduledAt: string | null;
dueAt: string | null;
nextAction: string | null;
ownerName: string | null;
assignedToName: string | null;
createdByName: string | null;
}
export interface ActivityListFilters {
search?: string;
primaryEntityType?: CrmActivityEntityType;

View File

@@ -44,6 +44,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Field label='Owner' value={activity.ownerName} />
<Field label='Activity Code' value={activity.activityCode} />
<Field label='Assignee' value={activity.assignedToName} />
<Field label='Primary Entity' value={activity.primaryEntityType} />
<Field label='Primary Record Id' value={activity.primaryEntityId} />

View File

@@ -0,0 +1,76 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDateTime } from '@/lib/date-format';
import type { ActivityTimelineItem } from '../api/types';
interface RecentActivitiesPanelProps {
items: ActivityTimelineItem[];
title?: string;
description?: string;
emptyMessage?: string;
}
function MetaItem({ label, value }: { label: string; value: string | null }) {
if (!value) {
return null;
}
return (
<span>
{label}: {value}
</span>
);
}
export function RecentActivitiesPanel({
items,
title = 'Recent Activities',
description = 'Unified view of new activities and legacy follow-up records.',
emptyMessage = 'No recent activities yet.'
}: RecentActivitiesPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
{emptyMessage}
</div>
) : (
items.map((item) => (
<div key={`${item.source}-${item.id}`} className='rounded-lg border p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='font-medium'>{item.subject}</div>
{item.activityCode ? <Badge variant='outline'>{item.activityCode}</Badge> : null}
<Badge variant={item.isLegacy ? 'secondary' : 'default'}>
{item.sourceLabel}
</Badge>
<Badge variant='outline'>{item.statusLabel}</Badge>
</div>
<div className='text-muted-foreground text-sm'>
{item.activityTypeLabel}
{item.primaryRecordLabel ? `${item.primaryRecordLabel}` : ''}
</div>
{item.description ? <div className='text-sm'>{item.description}</div> : null}
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs'>
<MetaItem label='When' value={formatDateTime(item.occurredAt)} />
<MetaItem label='Owner' value={item.ownerName} />
<MetaItem label='Created by' value={item.createdByName} />
<MetaItem label='Next action' value={item.nextAction} />
</div>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View File

@@ -1,6 +1,41 @@
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
import type { ActivityTimelineItem } from '../../api/types';
export interface LeadFollowupAdapterCandidate {
source: 'lead_followup';
followup: LeadFollowupSummary;
}
export function adaptLeadFollowupCandidate(
candidate: LeadFollowupAdapterCandidate
): ActivityTimelineItem {
const statusLabel =
candidate.followup.followupStatusLabel ??
formatCrmActivityLabel(candidate.followup.followupStatus);
return {
id: candidate.followup.id,
source: 'lead_followup',
sourceLabel: 'Lead Follow-up',
isLegacy: true,
activityId: null,
activityCode: null,
primaryEntityType: 'lead',
primaryEntityId: candidate.followup.leadId,
primaryRecordLabel: null,
activityType: 'follow_up',
activityTypeLabel: 'Follow Up',
subject: candidate.followup.note?.trim() || 'Lead follow-up',
description: candidate.followup.note,
status: candidate.followup.followupStatus,
statusLabel,
occurredAt: candidate.followup.followupDate,
scheduledAt: candidate.followup.followupDate,
dueAt: candidate.followup.nextFollowupDate,
nextAction: null,
ownerName: null,
assignedToName: null,
createdByName: candidate.followup.createdByName
};
}

View File

@@ -1,6 +1,42 @@
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
import type { ActivityTimelineItem } from '../../api/types';
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
export interface OpportunityFollowupAdapterCandidate {
source: 'opportunity_followup';
followup: OpportunityFollowupRecord;
followupTypeLabel?: string | null;
createdByName?: string | null;
}
export function adaptOpportunityFollowupCandidate(
candidate: OpportunityFollowupAdapterCandidate
): ActivityTimelineItem {
const typeLabel =
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
return {
id: candidate.followup.id,
source: 'opportunity_followup',
sourceLabel: 'Opportunity Follow-up',
isLegacy: true,
activityId: null,
activityCode: null,
primaryEntityType: 'opportunity',
primaryEntityId: candidate.followup.opportunityId,
primaryRecordLabel: null,
activityType: 'follow_up',
activityTypeLabel: typeLabel,
subject: typeLabel,
description: candidate.followup.outcome ?? candidate.followup.notes,
status: 'completed',
statusLabel: 'Completed',
occurredAt: candidate.followup.followupDate,
scheduledAt: candidate.followup.followupDate,
dueAt: candidate.followup.nextFollowupDate,
nextAction: candidate.followup.nextAction,
ownerName: null,
assignedToName: null,
createdByName: candidate.createdByName ?? null
};
}

View File

@@ -1,6 +1,42 @@
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
import type { ActivityTimelineItem } from '../../api/types';
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
export interface QuotationFollowupAdapterCandidate {
source: 'quotation_followup';
followup: QuotationFollowupRecord;
followupTypeLabel?: string | null;
createdByName?: string | null;
}
export function adaptQuotationFollowupCandidate(
candidate: QuotationFollowupAdapterCandidate
): ActivityTimelineItem {
const typeLabel =
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
return {
id: candidate.followup.id,
source: 'quotation_followup',
sourceLabel: 'Quotation Follow-up',
isLegacy: true,
activityId: null,
activityCode: null,
primaryEntityType: 'quotation',
primaryEntityId: candidate.followup.quotationId,
primaryRecordLabel: null,
activityType: 'follow_up',
activityTypeLabel: typeLabel,
subject: typeLabel,
description: candidate.followup.outcome ?? candidate.followup.notes,
status: 'completed',
statusLabel: 'Completed',
occurredAt: candidate.followup.followupDate,
scheduledAt: candidate.followup.followupDate,
dueAt: candidate.followup.nextFollowupDate,
nextAction: candidate.followup.nextAction,
ownerName: null,
assignedToName: null,
createdByName: candidate.createdByName ?? null
};
}

View File

@@ -0,0 +1,128 @@
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
import {
createArtifact,
downloadArtifact,
listArtifactsForEntity,
type DocumentArtifactRecord
} from '@/features/foundation/document-artifact/server/service';
import {
buildOrganizationStorageKey,
getStorageProvider
} from '@/features/foundation/storage/service';
import { AuthError } from '@/lib/auth/session';
import type { StoredObjectStream } from '@/features/foundation/storage/types';
import type { CrmSecurityContext } from '@/features/crm/security/server/service';
import { getActivityById } from './service';
const ACTIVITY_ATTACHMENT_ENTITY_TYPE = 'crm_activity';
const ACTIVITY_ATTACHMENT_DOCUMENT_TYPE = 'activity';
const ACTIVITY_ATTACHMENT_ARTIFACT_TYPE = 'attachment';
export type ActivityAttachmentKind =
| 'image'
| 'pdf'
| 'office_document'
| 'voice_recording'
| 'drawing'
| 'other';
export interface UploadActivityAttachmentInput {
organizationId: string;
userId: string;
activityId: string;
fileName: string;
contentType: string;
body: Buffer;
attachmentKind?: ActivityAttachmentKind;
context: CrmSecurityContext;
}
function assertAttachmentWritePermission(context: CrmSecurityContext) {
if (
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.create) &&
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.update)
) {
throw new AuthError('Forbidden', 403);
}
}
function buildAttachmentStorageKey(input: {
organizationId: string;
activityId: string;
fileName: string;
}) {
return buildOrganizationStorageKey(input.organizationId, [
'crm',
'activities',
input.activityId,
crypto.randomUUID(),
input.fileName
]);
}
export async function listActivityAttachments(input: {
organizationId: string;
activityId: string;
context: CrmSecurityContext;
}): Promise<DocumentArtifactRecord[]> {
await getActivityById(input.activityId, input.organizationId, input.context);
return listArtifactsForEntity({
organizationId: input.organizationId,
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
entityId: input.activityId,
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE
});
}
export async function uploadActivityAttachment(
input: UploadActivityAttachmentInput
): Promise<DocumentArtifactRecord> {
assertAttachmentWritePermission(input.context);
await getActivityById(input.activityId, input.organizationId, input.context);
const storedObject = await getStorageProvider().putObject({
key: buildAttachmentStorageKey(input),
body: input.body,
contentType: input.contentType,
fileName: input.fileName
});
return createArtifact({
organizationId: input.organizationId,
userId: input.userId,
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
entityId: input.activityId,
documentType: ACTIVITY_ATTACHMENT_DOCUMENT_TYPE,
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE,
storageProvider: storedObject.provider,
storageKey: storedObject.key,
fileName: storedObject.fileName ?? input.fileName,
contentType: storedObject.contentType,
fileSize: storedObject.size,
checksum: storedObject.checksum ?? null,
metadata: {
attachmentKind: input.attachmentKind ?? 'other'
}
});
}
export async function downloadActivityAttachment(input: {
organizationId: string;
userId: string;
activityId: string;
artifactId: string;
context: CrmSecurityContext;
}): Promise<{
artifact: DocumentArtifactRecord;
object: StoredObjectStream;
}> {
await getActivityById(input.activityId, input.organizationId, input.context);
return downloadArtifact({
artifactId: input.artifactId,
organizationId: input.organizationId,
userId: input.userId,
auditDownload: true
});
}

View File

@@ -12,6 +12,10 @@ function toRecordMetadata(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function getActivityCode(metadata: Record<string, unknown> | null): string | null {
return typeof metadata?.activityCode === 'string' ? metadata.activityCode : null;
}
function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean {
if (!row.isPricingSensitive) {
return true;
@@ -43,9 +47,11 @@ export async function hydrateActivityRecords(
const canViewPricingSensitiveContent = canViewPricingForRow(context, row);
const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent;
const metadata = toRecordMetadata(row.metadata);
return {
id: row.id,
activityCode: getActivityCode(metadata),
organizationId: row.organizationId,
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
primaryEntityId: row.primaryEntityId ?? null,
@@ -81,7 +87,7 @@ export async function hydrateActivityRecords(
isInternalOnly: row.isInternalOnly,
isPricingSensitive: row.isPricingSensitive,
parentActivityId: row.parentActivityId ?? null,
metadata: toRecordMetadata(row.metadata),
metadata,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),

View File

@@ -1,6 +1,7 @@
import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm';
import { crmActivities } from '@/db/schema';
import { db } from '@/lib/db';
import type { CrmActivityEntityType } from '@/features/crm/activity/types';
export type ActivityRow = typeof crmActivities.$inferSelect;
export type InsertActivityRow = typeof crmActivities.$inferInsert;
@@ -13,10 +14,19 @@ export interface ActivityListQuery {
activityType?: string;
ownerId?: string;
assignedToId?: string;
limit?: number;
}
export interface ActivityContextQuery {
organizationId: string;
contextEntityType: Exclude<CrmActivityEntityType, 'internal'>;
contextEntityId: string;
limit?: number;
}
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
const [created] = await db.insert(crmActivities).values(values).returning();
return created;
}
@@ -34,7 +44,10 @@ export async function updateActivityRow(
return updated;
}
export async function getActivityRow(id: string, organizationId: string): Promise<ActivityRow | undefined> {
export async function getActivityRow(
id: string,
organizationId: string
): Promise<ActivityRow | undefined> {
const [row] = await db
.select()
.from(crmActivities)
@@ -85,9 +98,59 @@ export async function listActivityRows(query: ActivityListQuery): Promise<Activi
filters.push(eq(crmActivities.assignedToId, query.assignedToId));
}
return db
let statement = db
.select()
.from(crmActivities)
.where(and(...filters))
.orderBy(desc(crmActivities.updatedAt), desc(crmActivities.createdAt));
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
.$dynamic();
if (query.limit) {
statement = statement.limit(query.limit);
}
return statement;
}
function buildContextFilter(query: ActivityContextQuery): SQL {
switch (query.contextEntityType) {
case 'customer':
return eq(crmActivities.customerId, query.contextEntityId);
case 'contact':
return eq(crmActivities.contactId, query.contextEntityId);
case 'lead':
return eq(crmActivities.leadId, query.contextEntityId);
case 'opportunity':
return eq(crmActivities.opportunityId, query.contextEntityId);
case 'quotation':
return eq(crmActivities.quotationId, query.contextEntityId);
case 'purchase_order':
return and(
eq(crmActivities.primaryEntityType, 'purchase_order'),
eq(crmActivities.primaryEntityId, query.contextEntityId)
)!;
}
}
export async function listActivityRowsByContext(
query: ActivityContextQuery
): Promise<ActivityRow[]> {
const filters: SQL[] = [
eq(crmActivities.organizationId, query.organizationId),
isNull(crmActivities.deletedAt),
buildContextFilter(query)
];
let statement = db
.select()
.from(crmActivities)
.where(and(...filters))
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
.$dynamic();
if (query.limit) {
statement = statement.limit(query.limit);
}
return statement;
}

View File

@@ -2,6 +2,8 @@ import { and, eq, isNull } from 'drizzle-orm';
import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
CRM_ACTIVITY_ENTITY_TYPES,
CRM_ACTIVITY_PERMISSIONS,
@@ -47,6 +49,9 @@ import { hydrateActivityRecords } from './read-model';
type ActivityAccessContext = CrmSecurityContext;
const ACTIVITY_DOCUMENT_TYPE = 'activity';
const ACTIVITY_TYPE_CATEGORY = 'crm_activity_type';
interface PrimaryRecordContext {
primaryEntityId: string | null;
primaryRecordLabel: string | null;
@@ -388,6 +393,30 @@ function mapOption(value: string): ActivityOption {
return { value, label: formatCrmActivityLabel(value) };
}
async function listGovernedActivityTypeOptions(organizationId: string): Promise<ActivityOption[]> {
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
if (!options.length) {
return CRM_ACTIVITY_TYPES.map(mapOption);
}
return options.map((option) => ({
value: option.id,
label: option.label
}));
}
async function assertActivityTypeValue(organizationId: string, activityType: string) {
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
const allowedValues = options.length
? new Set(options.flatMap((option) => [option.id, option.code]))
: new Set<string>(CRM_ACTIVITY_TYPES);
if (!allowedValues.has(activityType)) {
throw new AuthError('Invalid activity type', 400);
}
}
function filterByEffectiveStatus(items: ActivityRecord[], status?: string) {
if (!status) {
return items;
@@ -489,11 +518,14 @@ async function buildActivityWriteValues(
export async function getActivityReferenceData(
organizationId: string
): Promise<ActivityReferenceData> {
const assignableUsers = await listAssignableUsers(organizationId);
const [assignableUsers, activityTypes] = await Promise.all([
listAssignableUsers(organizationId),
listGovernedActivityTypeOptions(organizationId)
]);
return {
entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption),
activityTypes: CRM_ACTIVITY_TYPES.map(mapOption),
activityTypes,
statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption),
priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption),
assignableUsers
@@ -555,6 +587,25 @@ export async function createActivity(
if (!values.subject) {
throw new AuthError('Subject is required', 400);
}
await assertActivityTypeValue(organizationId, values.activityType);
const sequence = await generateNextDocumentCode({
organizationId,
documentType: ACTIVITY_DOCUMENT_TYPE,
branchId: values.branchId,
productType: values.productType
});
const metadata = {
...values.metadata,
activityCode: sequence.code,
documentSequence: {
documentType: sequence.documentType,
branchId: sequence.branchId,
productType: sequence.productType,
period: sequence.period,
currentNumber: sequence.currentNumber
}
};
const created = await insertActivity({
id: crypto.randomUUID(),
@@ -590,7 +641,7 @@ export async function createActivity(
isInternalOnly: values.isInternalOnly,
isPricingSensitive: values.isPricingSensitive,
parentActivityId: null,
metadata: values.metadata,
metadata,
createdBy: userId,
updatedBy: userId
});
@@ -614,6 +665,7 @@ export async function updateActivity(
);
const values = await buildActivityWriteValues(organizationId, userId, context, payload, current);
await assertActivityTypeValue(organizationId, values.activityType);
const nextStatus = values.status;
await updateActivityRow(id, organizationId, {

View File

@@ -0,0 +1,217 @@
import {
CRM_ACTIVITY_PERMISSIONS,
formatCrmActivityLabel,
type CrmActivityEntityType
} from '@/features/crm/activity/types';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { resolveUserDisplays } from '@/features/foundation/display/server/display-resolver';
import {
canAccessScopedCrmRecord,
type CrmSecurityContext
} from '@/features/crm/security/server/service';
import type { ActivityRecord, ActivityTimelineItem } from '../api/types';
import { hydrateActivityRecords } from './read-model';
import { listActivityRowsByContext } from './repository';
import {
adaptLeadFollowupCandidate
} from './adapters/lead-followup.adapter';
import {
adaptOpportunityFollowupCandidate
} from './adapters/opportunity-followup.adapter';
import {
adaptQuotationFollowupCandidate
} from './adapters/quotation-followup.adapter';
import { listLeadFollowups } from '@/features/crm/leads/server/service';
import { listOpportunityFollowups } from '@/features/crm/opportunities/server/service';
import { listQuotationFollowups } from '@/features/crm/quotations/server/service';
const FOLLOWUP_TYPE_CATEGORY = 'crm_followup_type';
type TimelineEntityType = Exclude<CrmActivityEntityType, 'internal' | 'purchase_order'>;
interface TimelineContextInput {
organizationId: string;
entityType: TimelineEntityType;
entityId: string;
context: CrmSecurityContext;
limit?: number;
}
function hasActivityReadPermission(context: CrmSecurityContext): boolean {
return context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.read);
}
function isManagerLike(context: CrmSecurityContext): boolean {
const roles = context.businessRoles ?? [context.businessRole];
return (
context.membershipRole === 'admin' ||
context.ownershipScope === 'organization' ||
context.ownershipScope === 'team' ||
roles.some((role) => role.includes('manager') || role === 'crm_admin')
);
}
function canViewActivityTimelineItem(context: CrmSecurityContext, activity: ActivityRecord): boolean {
const isDirectUser =
activity.createdBy === context.userId ||
activity.ownerId === context.userId ||
activity.assignedToId === context.userId;
if (activity.isInternalOnly) {
return isDirectUser || isManagerLike(context);
}
return (
isDirectUser ||
canAccessScopedCrmRecord(context, {
branchId: activity.branchId,
productType: activity.productType,
ownerUserId: activity.ownerId
})
);
}
function resolveActivityOccurredAt(activity: ActivityRecord): string {
return (
activity.completedAt ??
activity.cancelledAt ??
activity.skippedAt ??
activity.scheduledStartAt ??
activity.dueAt ??
activity.createdAt
);
}
function mapActivityToTimelineItem(activity: ActivityRecord): ActivityTimelineItem {
return {
id: activity.id,
source: 'activity',
sourceLabel: 'Activity',
isLegacy: false,
activityId: activity.id,
activityCode: activity.activityCode,
primaryEntityType: activity.primaryEntityType,
primaryEntityId: activity.primaryEntityId,
primaryRecordLabel: activity.primaryRecordLabel,
activityType: activity.activityType,
activityTypeLabel: formatCrmActivityLabel(activity.activityType),
subject: activity.subject,
description: activity.description,
status: activity.effectiveStatus,
statusLabel: formatCrmActivityLabel(activity.effectiveStatus),
occurredAt: resolveActivityOccurredAt(activity),
scheduledAt: activity.scheduledStartAt,
dueAt: activity.dueAt,
nextAction: activity.nextAction,
ownerName: activity.ownerName,
assignedToName: activity.assignedToName,
createdByName: activity.createdByName
};
}
function sortTimelineItems(items: ActivityTimelineItem[]): ActivityTimelineItem[] {
return items.toSorted((left, right) => {
return new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime();
});
}
async function buildFollowupTypeLabelMap(organizationId: string): Promise<Map<string, string>> {
const options = await getActiveOptionsByCategory(FOLLOWUP_TYPE_CATEGORY, { organizationId });
return new Map(options.map((option) => [option.id, option.label]));
}
async function buildUserDisplayMap(items: Array<{ createdBy: string }>): Promise<Map<string, string>> {
const userIds = [...new Set(items.map((item) => item.createdBy).filter(Boolean))];
const displayMap = await resolveUserDisplays(userIds);
return new Map(
[...displayMap.entries()].map(([userId, record]) => [userId, record.displayName])
);
}
async function listLegacyTimelineItems(
input: TimelineContextInput
): Promise<ActivityTimelineItem[]> {
if (input.entityType === 'customer' || input.entityType === 'contact') {
return [];
}
if (input.entityType === 'lead') {
const followups = await listLeadFollowups(input.entityId, input.organizationId, input.context);
return followups.map((followup) =>
adaptLeadFollowupCandidate({
source: 'lead_followup',
followup
})
);
}
if (input.entityType === 'opportunity') {
const [followupTypeLabelMap, followups] = await Promise.all([
buildFollowupTypeLabelMap(input.organizationId),
listOpportunityFollowups(input.entityId, input.organizationId, input.context)
]);
const userDisplayMap = await buildUserDisplayMap(followups);
return followups.map((followup) =>
adaptOpportunityFollowupCandidate({
source: 'opportunity_followup',
followup,
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
createdByName: userDisplayMap.get(followup.createdBy) ?? null
})
);
}
const [followupTypeLabelMap, followups] = await Promise.all([
buildFollowupTypeLabelMap(input.organizationId),
listQuotationFollowups(input.entityId, input.organizationId)
]);
const userDisplayMap = await buildUserDisplayMap(followups);
return followups.map((followup) =>
adaptQuotationFollowupCandidate({
source: 'quotation_followup',
followup,
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
createdByName: userDisplayMap.get(followup.createdBy) ?? null
})
);
}
async function listCurrentTimelineItems(input: TimelineContextInput): Promise<ActivityTimelineItem[]> {
const rows = await listActivityRowsByContext({
organizationId: input.organizationId,
contextEntityType: input.entityType,
contextEntityId: input.entityId,
limit: input.limit ? input.limit * 2 : undefined
});
const hydrated = await hydrateActivityRecords(rows, input.context);
return hydrated.filter((activity) => canViewActivityTimelineItem(input.context, activity)).map(mapActivityToTimelineItem);
}
export async function listRecentActivitiesForContext(
input: TimelineContextInput
): Promise<ActivityTimelineItem[]> {
if (!hasActivityReadPermission(input.context)) {
return [];
}
const [currentItems, legacyItems] = await Promise.all([
listCurrentTimelineItems(input),
listLegacyTimelineItems(input)
]);
const merged = sortTimelineItems([...currentItems, ...legacyItems]);
if (!input.limit) {
return merged;
}
return merged.slice(0, input.limit);
}

View File

@@ -11,14 +11,19 @@ export const CRM_ACTIVITY_ENTITY_TYPES = [
] as const;
export const CRM_ACTIVITY_TYPES = [
'follow_up',
'meeting',
'visit',
'phone_call',
'email_follow_up',
'email',
'site_survey',
'presentation',
'reminder',
'internal_task',
'follow_up',
'delivery_coordination',
'approval_action',
// Backward-compatible aliases kept while master options become the source of truth.
'email_follow_up',
'reminder_action',
'other'
] as const;

View File

@@ -160,6 +160,7 @@ export interface CustomerDetailResponse {
message: string;
customer: CustomerRecord;
activity: CustomerActivityRecord[];
recentActivities: ActivityTimelineItem[];
ownerHistory: CustomerOwnerHistoryRecord[];
}
@@ -227,3 +228,4 @@ export interface MutationSuccessResponse {
message: string;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDateTime } from '@/lib/format';
import { formatNumber } from '@/lib/number-format';
@@ -126,6 +127,12 @@ export function CustomerDetail({
<div className='space-y-6 lg:col-span-2'>
<Card>
<CardContent className='pt-6'>
<RecentActivitiesPanel
items={data.recentActivities}
description='Recent customer-linked activity records shown alongside existing audit history.'
emptyMessage='No recent customer activity yet.'
/>
<Tabs defaultValue='overview' className='gap-4'>
<TabsList>
<TabsTrigger value='overview'></TabsTrigger>

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
@@ -112,6 +113,12 @@ export function LeadDetail({
</CardContent>
</Card>
<RecentActivitiesPanel
items={data.recentActivities}
description='New activity records and legacy lead follow-ups shown together.'
emptyMessage='No recent activity or lead follow-up yet.'
/>
<Tabs defaultValue='followups' className='space-y-4'>
<TabsList>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>

View File

@@ -213,6 +213,7 @@ export interface LeadDetailResponse {
message: string;
lead: LeadDetail;
followups: LeadFollowupSummary[];
recentActivities: ActivityTimelineItem[];
referenceData: LeadReferenceData;
}
@@ -223,3 +224,4 @@ export interface LeadMutationSuccessResponse {
followup?: LeadFollowupSummary;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';

View File

@@ -224,6 +224,7 @@ export interface OpportunityDetailResponse {
message: string;
opportunity: OpportunityRecord;
activity: OpportunityActivityRecord[];
recentActivities: ActivityTimelineItem[];
}
export interface OpportunityProjectPartiesResponse {
@@ -338,3 +339,4 @@ export interface MutationSuccessResponse {
message: string;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';

View File

@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDate, formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
@@ -317,6 +318,12 @@ remark: item.remark ?? ''
</CardContent>
</Card>
<RecentActivitiesPanel
items={data.recentActivities}
description='New activity records and legacy opportunity follow-ups shown together.'
emptyMessage='No recent activity or opportunity follow-up yet.'
/>
<Tabs defaultValue='followups' className='space-y-4'>
<TabsList>
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>

View File

@@ -332,6 +332,7 @@ export interface QuotationDetailResponse {
message: string;
quotation: QuotationRecord;
activity: QuotationActivityRecord[];
recentActivities: ActivityTimelineItem[];
}
export interface QuotationItemsResponse {
@@ -488,3 +489,4 @@ export interface QuotationCustomerPackageGenerateResponse {
} | null;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';

View File

@@ -33,6 +33,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
import { formatDate, formatDateTime } from "@/lib/date-format";
import { formatNumber } from "@/lib/number-format";
@@ -1380,6 +1381,12 @@ export function QuotationDetail({
<div className="space-y-6 lg:col-span-2">
<Card>
<CardContent className="pt-6">
<RecentActivitiesPanel
items={data.recentActivities}
description="New activity records and legacy quotation follow-ups shown together."
emptyMessage="No recent activity or quotation follow-up yet."
/>
<Tabs defaultValue="overview" className="gap-4">
<TabsList className="flex flex-wrap">
<TabsTrigger value="overview">Overview</TabsTrigger>

View File

@@ -92,7 +92,7 @@ export async function createArtifact(input: {
entityType: string;
entityId: string;
documentType: string;
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export' | 'attachment';
templateVersionId?: string | null;
storageProvider: string;
storageKey: string;
@@ -189,6 +189,29 @@ export async function getActiveArtifactForEntity(input: {
return enrichArtifact(artifact);
}
export async function listArtifactsForEntity(input: {
organizationId: string;
entityType: string;
entityId: string;
artifactType?: string;
}) {
const rows = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.organizationId, input.organizationId),
eq(crmDocumentArtifacts.entityType, input.entityType),
eq(crmDocumentArtifacts.entityId, input.entityId),
...(input.artifactType ? [eq(crmDocumentArtifacts.artifactType, input.artifactType)] : []),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.orderBy(desc(crmDocumentArtifacts.createdAt));
return Promise.all(rows.map((row) => enrichArtifact(row)));
}
export async function lockArtifact(input: {
artifactId: string;
organizationId: string;

View File

@@ -38,6 +38,7 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
crm_opportunity: "EN",
quotation: "QT",
approval: "APV",
activity: "ACT",
};
type OrganizationRecord = {