This commit is contained in:
phaichayon
2026-06-15 16:34:41 +07:00
parent 1d2406483e
commit eae0dee1f2
40 changed files with 9982 additions and 132 deletions

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
createQuotationAttachment,
getQuotationDetail,
listQuotationAttachments,
softDeleteQuotationAttachment,
updateQuotationAttachment
} from '@/features/crm/quotations/server/service';
import { quotationAttachmentSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const updateSchema = quotationAttachmentSchema.extend({
id: z.string().min(1, 'Attachment id is required')
});
const deleteSchema = z.object({
id: z.string().min(1, 'Attachment id is required')
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationAttachments(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation attachments loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation attachments' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationAttachmentManage
});
const payload = quotationAttachmentSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationAttachment(id, organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_attachment',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation attachment created successfully',
attachment: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation attachment' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationAttachmentManage
});
const payload = updateSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationAttachments(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation attachment not found' }, { status: 404 });
}
const updated = await updateQuotationAttachment(id, payload.id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_attachment',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation attachment updated successfully',
attachment: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation attachment' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationAttachmentManage
});
const payload = deleteSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationAttachments(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation attachment not found' }, { status: 404 });
}
const updated = await softDeleteQuotationAttachment(id, payload.id, organization.id);
await auditDelete({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_attachment',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation attachment deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation attachment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
createQuotationCustomer,
getQuotationDetail,
listQuotationCustomers,
softDeleteQuotationCustomer,
updateQuotationCustomer
} from '@/features/crm/quotations/server/service';
import { quotationCustomerSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const updateSchema = quotationCustomerSchema.extend({
id: z.string().min(1, 'Relation id is required')
});
const deleteSchema = z.object({
id: z.string().min(1, 'Relation id is required')
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationCustomers(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation customers loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation customers' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationCustomerManage
});
const payload = quotationCustomerSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationCustomer(id, organization.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_customer',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation customer created successfully',
relation: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation customer' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationCustomerManage
});
const payload = updateSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationCustomers(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation customer relation not found' }, { status: 404 });
}
const updated = await updateQuotationCustomer(id, payload.id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_customer',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation customer updated successfully',
relation: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation customer' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationCustomerManage
});
const payload = deleteSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationCustomers(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation customer relation not found' }, { status: 404 });
}
const updated = await softDeleteQuotationCustomer(id, payload.id, organization.id);
await auditDelete({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_customer',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation customer deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation customer' }, { status: 500 });
}
}

View File

@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
createQuotationFollowup,
getQuotationDetail,
listQuotationFollowups,
softDeleteQuotationFollowup,
updateQuotationFollowup
} from '@/features/crm/quotations/server/service';
import { quotationFollowupSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const updateSchema = quotationFollowupSchema.extend({
id: z.string().min(1, 'Follow-up id is required')
});
const deleteSchema = z.object({
id: z.string().min(1, 'Follow-up id is required')
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationFollowups(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation follow-ups loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation follow-ups' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationFollowupManage
});
const payload = quotationFollowupSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationFollowup(id, organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_followup',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation follow-up created successfully',
followup: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation follow-up' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationFollowupManage
});
const payload = updateSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationFollowups(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation follow-up not found' }, { status: 404 });
}
const updated = await updateQuotationFollowup(
id,
payload.id,
organization.id,
session.user.id,
payload
);
await auditUpdate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_followup',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation follow-up updated successfully',
followup: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation follow-up' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationFollowupManage
});
const payload = deleteSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationFollowups(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation follow-up not found' }, { status: 404 });
}
const updated = await softDeleteQuotationFollowup(
id,
payload.id,
organization.id,
session.user.id
);
await auditDelete({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_followup',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation follow-up deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation follow-up' }, { status: 500 });
}
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getQuotationDetail,
listQuotationItems,
softDeleteQuotationItem,
updateQuotationItem
} from '@/features/crm/quotations/server/service';
import { quotationItemSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; itemId: string }>;
};
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, itemId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationItemManage
});
const payload = quotationItemSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationItems(id, organization.id)).find((item) => item.id === itemId);
if (!before) {
return NextResponse.json({ message: 'Quotation item not found' }, { status: 404 });
}
const updated = await updateQuotationItem(id, itemId, organization.id, session.user.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_item',
entityId: itemId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation item updated successfully',
item: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation item' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, itemId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationItemManage
});
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationItems(id, organization.id)).find((item) => item.id === itemId);
if (!before) {
return NextResponse.json({ message: 'Quotation item not found' }, { status: 404 });
}
const updated = await softDeleteQuotationItem(id, itemId, organization.id, session.user.id);
await auditDelete({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_item',
entityId: itemId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation item deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation item' }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createQuotationItem,
getQuotationDetail,
listQuotationItems
} from '@/features/crm/quotations/server/service';
import { quotationItemSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationItems(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation items loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation items' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationItemManage
});
const payload = quotationItemSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationItem(id, organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_item',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation item created successfully',
item: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation item' }, { status: 500 });
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createQuotationRevision,
getQuotationDetail,
listQuotationRevisions
} from '@/features/crm/quotations/server/service';
import { quotationRevisionSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationRevisions(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation revisions loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation revisions' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRevisionCreate
});
const payload = quotationRevisionSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationRevision(
id,
organization.id,
session.user.id,
payload.revisionRemark
);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation revision created successfully',
quotation: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation revision' }, { status: 500 });
}
}

View File

@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getQuotationActivity,
getQuotationDetail,
softDeleteQuotation,
updateQuotation
} from '@/features/crm/quotations/server/service';
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const [quotation, activity] = await Promise.all([
getQuotationDetail(id, organization.id),
getQuotationActivity(id, organization.id)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation loaded successfully',
quotation,
activity
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationUpdate
});
const payload = quotationSchema.parse(await request.json());
const before = await getQuotationDetail(id, organization.id);
const updated = await updateQuotation(id, organization.id, session.user.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_quotation',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation updated successfully',
quotation: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationDelete
});
const before = await getQuotationDetail(id, organization.id);
const updated = await softDeleteQuotation(id, organization.id, session.user.id);
await auditDelete({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_quotation',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation' }, { status: 500 });
}
}

View File

@@ -0,0 +1,202 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
createQuotationTopic,
getQuotationDetail,
listQuotationTopics,
softDeleteQuotationTopic,
updateQuotationTopic
} from '@/features/crm/quotations/server/service';
import { quotationTopicSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const deleteSchema = z.object({
id: z.string().min(1, 'Topic id is required')
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const items = await listQuotationTopics(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation topics loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotation topics' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationTopicManage
});
const payload = quotationTopicSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const created = await createQuotationTopic(id, organization.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_topic',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation topic created successfully',
topic: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation topic' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationTopicManage
});
const payload = quotationTopicSchema.extend({
id: z.string().min(1, 'Topic id is required')
}).parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationTopics(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation topic not found' }, { status: 404 });
}
const updated = await updateQuotationTopic(id, payload.id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_topic',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation topic updated successfully',
topic: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update quotation topic' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationTopicManage
});
const payload = deleteSchema.parse(await request.json());
const quotation = await getQuotationDetail(id, organization.id);
const before = (await listQuotationTopics(id, organization.id)).find(
(item) => item.id === payload.id
);
if (!before) {
return NextResponse.json({ message: 'Quotation topic not found' }, { status: 404 });
}
const updated = await softDeleteQuotationTopic(id, payload.id, organization.id);
await auditDelete({
organizationId: organization.id,
branchId: quotation.branchId,
userId: session.user.id,
entityType: 'crm_quotation_topic',
entityId: payload.id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Quotation topic deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete quotation topic' }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { createQuotation, listQuotations } from '@/features/crm/quotations/server/service';
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const status = searchParams.get('status') ?? undefined;
const quotationType = searchParams.get('quotationType') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const enquiry = searchParams.get('enquiry') ?? undefined;
const hot = searchParams.get('hot') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listQuotations(organization.id, {
page,
limit,
search,
status,
quotationType,
branch,
customer,
enquiry,
hot,
sort
});
const offset = (page - 1) * limit;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotations loaded successfully',
totalItems: result.totalItems,
offset,
limit,
items: result.items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load quotations' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationCreate
});
const payload = quotationSchema.parse(await request.json());
const created = await createQuotation(organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: created.branchId,
userId: session.user.id,
entityType: 'crm_quotation',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Quotation created successfully',
quotation: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create quotation' }, { status: 500 });
}
}

View File

@@ -5,6 +5,7 @@ import { CustomerDetail } from '@/features/crm/customers/components/customer-det
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -55,6 +56,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
canRead && session?.user?.activeOrganizationId
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
: [];
const relatedQuotations =
canRead && session?.user?.activeOrganizationId
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId)
: [];
return (
<PageContainer
@@ -79,6 +84,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
delete: canContactDelete
}}
relatedEnquiries={relatedEnquiries}
relatedQuotations={relatedQuotations}
/>
</HydrationBoundary>
) : null}

View File

@@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -50,6 +51,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
canRead && session?.user?.activeOrganizationId
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
const relatedQuotations =
canRead && session?.user?.activeOrganizationId
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
: [];
return (
<PageContainer
@@ -67,6 +72,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
<EnquiryDetail
enquiryId={id}
referenceData={referenceData}
relatedQuotations={relatedQuotations}
canUpdate={canUpdate}
canManageFollowups={{
create: canFollowupCreate,

View File

@@ -1,5 +1,19 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
import {
quotationAttachmentsOptions,
quotationByIdOptions,
quotationCustomersOptions,
quotationFollowupsOptions,
quotationItemsOptions,
quotationRevisionsOptions,
quotationTopicsOptions
} from '@/features/crm/quotations/api/queries';
import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail';
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ id: string }>;
@@ -7,24 +21,90 @@ type PageProps = {
export default async function QuotationDetailRoute({ params }: PageProps) {
const { id } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationUpdate)));
const canManageItems =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationItemManage)));
const canManageCustomers =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationCustomerManage)));
const canManageTopics =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationTopicManage)));
const canManageFollowups =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationFollowupManage)));
const canManageAttachments =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationAttachmentManage)));
const canCreateRevision =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate)));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(quotationByIdOptions(id));
void queryClient.prefetchQuery(quotationItemsOptions(id));
void queryClient.prefetchQuery(quotationCustomersOptions(id));
void queryClient.prefetchQuery(quotationTopicsOptions(id));
void queryClient.prefetchQuery(quotationFollowupsOptions(id));
void queryClient.prefetchQuery(quotationAttachmentsOptions(id));
void queryClient.prefetchQuery(quotationRevisionsOptions(id));
}
const referenceData =
canRead && session?.user?.activeOrganizationId
? await getQuotationReferenceData(session.user.activeOrganizationId)
: null;
return (
<PageContainer
pageTitle='Quotation Detail'
pageDescription={`Production detail route placeholder for quotation ${id}.`}
pageDescription='Commercial detail, child records, revisions, and audit activity.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this quotation.
</div>
}
>
<CrmProductionPlaceholder
title='Quotation detail pending'
summary='This production detail route is isolated from the legacy quotation preview and approval mock flow.'
foundationItems={[
'Document sequence helper',
'Branch scope validation',
'Master option lookups',
'Audit trail foundation'
]}
nextStep='Implement production quotation detail with real approval, preview, and related-entity data.'
demoHref={`/dashboard/crm-demo/quotations/${id}`}
/>
{referenceData ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationDetail
quotationId={id}
referenceData={referenceData}
canUpdate={canUpdate}
canManageItems={canManageItems}
canManageCustomers={canManageCustomers}
canManageTopics={canManageTopics}
canManageFollowups={canManageFollowups}
canManageAttachments={canManageAttachments}
canCreateRevision={canCreateRevision}
/>
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -1,28 +1,68 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
import QuotationListing from '@/features/crm/quotations/components/quotation-listing';
import { QuotationFormSheetTrigger } from '@/features/crm/quotations/components/quotation-form-sheet';
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: CRM Quotations'
};
export default function QuotationsRoute() {
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function QuotationsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDelete)));
searchParamsCache.parse(searchParams);
const referenceData =
canRead && session?.user?.activeOrganizationId
? await getQuotationReferenceData(session.user.activeOrganizationId)
: null;
return (
<PageContainer
pageTitle='Quotations'
pageDescription='Production quotation flows will be built on foundation services, not on the legacy demo layer.'
pageDescription='Production quotation register with revisions, related parties, and server-calculated items.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM quotations.
</div>
}
pageHeaderAction={
canCreate && referenceData ? <QuotationFormSheetTrigger referenceData={referenceData} /> : null
}
>
<CrmProductionPlaceholder
title='Quotation module pending'
summary='The mock quotation list, kanban, and detail experiences now live only under crm-demo.'
foundationItems={[
'Quotation status master options',
'Server-side document sequence generation',
'Organization and branch context',
'Audit log helper for future status changes'
]}
nextStep='Implement quotation persistence and workflow after enquiry production APIs are ready.'
demoHref='/dashboard/crm-demo/quotations'
/>
{referenceData ? (
<QuotationListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
) : null}
</PageContainer>
);
}