diff --git a/docs/implementation/task-ep1.2-activity-integration-legacy-followup-adapter-2026-07-13.md b/docs/implementation/task-ep1.2-activity-integration-legacy-followup-adapter-2026-07-13.md new file mode 100644 index 0000000..b83ea15 --- /dev/null +++ b/docs/implementation/task-ep1.2-activity-integration-legacy-followup-adapter-2026-07-13.md @@ -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. diff --git a/plans/task-ep.1.2.md b/plans/task-ep.1.2.md new file mode 100644 index 0000000..1dc5d01 --- /dev/null +++ b/plans/task-ep.1.2.md @@ -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. diff --git a/src/app/api/crm/customers/[id]/route.ts b/src/app/api/crm/customers/[id]/route.ts index 7ab53c4..f57b337 100644 --- a/src/app/api/crm/customers/[id]/route.ts +++ b/src/app/api/crm/customers/[id]/route.ts @@ -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) { diff --git a/src/app/api/crm/leads/[id]/route.ts b/src/app/api/crm/leads/[id]/route.ts index 5a1df2b..422344a 100644 --- a/src/app/api/crm/leads/[id]/route.ts +++ b/src/app/api/crm/leads/[id]/route.ts @@ -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) { diff --git a/src/app/api/crm/opportunities/[id]/route.ts b/src/app/api/crm/opportunities/[id]/route.ts index 7a77ac1..4761128 100644 --- a/src/app/api/crm/opportunities/[id]/route.ts +++ b/src/app/api/crm/opportunities/[id]/route.ts @@ -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) { diff --git a/src/app/api/crm/quotations/[id]/route.ts b/src/app/api/crm/quotations/[id]/route.ts index 7629783..9911949 100644 --- a/src/app/api/crm/quotations/[id]/route.ts +++ b/src/app/api/crm/quotations/[id]/route.ts @@ -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) { diff --git a/src/features/crm/activities/api/types.ts b/src/features/crm/activities/api/types.ts index 8e268d7..e0dc318 100644 --- a/src/features/crm/activities/api/types.ts +++ b/src/features/crm/activities/api/types.ts @@ -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; diff --git a/src/features/crm/activities/components/activity-detail.tsx b/src/features/crm/activities/components/activity-detail.tsx index 5a86b33..890d296 100644 --- a/src/features/crm/activities/components/activity-detail.tsx +++ b/src/features/crm/activities/components/activity-detail.tsx @@ -44,6 +44,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
+ diff --git a/src/features/crm/activities/components/recent-activities-panel.tsx b/src/features/crm/activities/components/recent-activities-panel.tsx new file mode 100644 index 0000000..f1208b5 --- /dev/null +++ b/src/features/crm/activities/components/recent-activities-panel.tsx @@ -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 ( + + {label}: {value} + + ); +} + +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 ( + + + {title} + {description} + + + {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + items.map((item) => ( +
+
+
+
+
{item.subject}
+ {item.activityCode ? {item.activityCode} : null} + + {item.sourceLabel} + + {item.statusLabel} +
+
+ {item.activityTypeLabel} + {item.primaryRecordLabel ? ` • ${item.primaryRecordLabel}` : ''} +
+ {item.description ?
{item.description}
: null} +
+ + + + +
+
+
+
+ )) + )} +
+
+ ); +} diff --git a/src/features/crm/activities/server/adapters/lead-followup.adapter.ts b/src/features/crm/activities/server/adapters/lead-followup.adapter.ts index e8f51db..9a1e1c3 100644 --- a/src/features/crm/activities/server/adapters/lead-followup.adapter.ts +++ b/src/features/crm/activities/server/adapters/lead-followup.adapter.ts @@ -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 + }; +} diff --git a/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts b/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts index 7d59b14..a087870 100644 --- a/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts +++ b/src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts @@ -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 + }; } diff --git a/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts b/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts index 75d5f40..3a1c8d6 100644 --- a/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts +++ b/src/features/crm/activities/server/adapters/quotation-followup.adapter.ts @@ -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 + }; } diff --git a/src/features/crm/activities/server/attachment-service.ts b/src/features/crm/activities/server/attachment-service.ts new file mode 100644 index 0000000..ec250f5 --- /dev/null +++ b/src/features/crm/activities/server/attachment-service.ts @@ -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 { + 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 { + 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 + }); +} diff --git a/src/features/crm/activities/server/read-model.ts b/src/features/crm/activities/server/read-model.ts index 9941058..3048db7 100644 --- a/src/features/crm/activities/server/read-model.ts +++ b/src/features/crm/activities/server/read-model.ts @@ -12,6 +12,10 @@ function toRecordMetadata(value: unknown): Record | null { return value as Record; } +function getActivityCode(metadata: Record | 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(), diff --git a/src/features/crm/activities/server/repository.ts b/src/features/crm/activities/server/repository.ts index b3c8ead..fcc6a2d 100644 --- a/src/features/crm/activities/server/repository.ts +++ b/src/features/crm/activities/server/repository.ts @@ -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; + contextEntityId: string; + limit?: number; } export async function insertActivity(values: InsertActivityRow): Promise { 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 { +export async function getActivityRow( + id: string, + organizationId: string +): Promise { const [row] = await db .select() .from(crmActivities) @@ -85,9 +98,59 @@ export async function listActivityRows(query: ActivityListQuery): Promise { + 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; } diff --git a/src/features/crm/activities/server/service.ts b/src/features/crm/activities/server/service.ts index 3173c54..892a650 100644 --- a/src/features/crm/activities/server/service.ts +++ b/src/features/crm/activities/server/service.ts @@ -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 { + 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(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 { - 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, { diff --git a/src/features/crm/activities/server/timeline-service.ts b/src/features/crm/activities/server/timeline-service.ts new file mode 100644 index 0000000..a8ac013 --- /dev/null +++ b/src/features/crm/activities/server/timeline-service.ts @@ -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; + +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> { + 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> { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/features/crm/activity/types.ts b/src/features/crm/activity/types.ts index 427994f..b504041 100644 --- a/src/features/crm/activity/types.ts +++ b/src/features/crm/activity/types.ts @@ -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; diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts index c349dd7..998bd47 100644 --- a/src/features/crm/customers/api/types.ts +++ b/src/features/crm/customers/api/types.ts @@ -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'; diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 6adfa13..91f1fac 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -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({
+ + ภาพรวม diff --git a/src/features/crm/leads/components/lead-detail.tsx b/src/features/crm/leads/components/lead-detail.tsx index 728b9eb..24bc31f 100644 --- a/src/features/crm/leads/components/lead-detail.tsx +++ b/src/features/crm/leads/components/lead-detail.tsx @@ -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({ + + Follow-ups diff --git a/src/features/crm/leads/types.ts b/src/features/crm/leads/types.ts index d825ede..16b86a0 100644 --- a/src/features/crm/leads/types.ts +++ b/src/features/crm/leads/types.ts @@ -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'; diff --git a/src/features/crm/opportunities/api/types.ts b/src/features/crm/opportunities/api/types.ts index cc1d19c..0c21f12 100644 --- a/src/features/crm/opportunities/api/types.ts +++ b/src/features/crm/opportunities/api/types.ts @@ -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'; diff --git a/src/features/crm/opportunities/components/opportunity-detail.tsx b/src/features/crm/opportunities/components/opportunity-detail.tsx index 7e4d8c2..2840b90 100644 --- a/src/features/crm/opportunities/components/opportunity-detail.tsx +++ b/src/features/crm/opportunities/components/opportunity-detail.tsx @@ -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 ?? '' + + Follow-up Timeline diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts index 5f327e1..28d3a66 100644 --- a/src/features/crm/quotations/api/types.ts +++ b/src/features/crm/quotations/api/types.ts @@ -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'; diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index 45f9040..f65cc99 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -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({
+ + Overview diff --git a/src/features/foundation/document-artifact/server/service.ts b/src/features/foundation/document-artifact/server/service.ts index d623dfa..71462b1 100644 --- a/src/features/foundation/document-artifact/server/service.ts +++ b/src/features/foundation/document-artifact/server/service.ts @@ -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; diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts index 23c2370..4aaa8ba 100644 --- a/src/features/foundation/document-sequence/service.ts +++ b/src/features/foundation/document-sequence/service.ts @@ -38,6 +38,7 @@ const DEFAULT_DOCUMENT_PREFIXES: Record = { crm_opportunity: "EN", quotation: "QT", approval: "APV", + activity: "ACT", }; type OrganizationRecord = {