commit
This commit is contained in:
64
src/modules/auth/controller.ts
Normal file
64
src/modules/auth/controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authPlugin } from "@/middleware/auth";
|
||||
import type { User } from "@/database/schema";
|
||||
import type { KeycloakTokenPayload } from "@/lib/keycloak";
|
||||
|
||||
export const auth = new Elysia({ prefix: "/auth", tags: ["auth"] })
|
||||
.use(authPlugin)
|
||||
// GET /api/auth/me - Get current user info
|
||||
.get(
|
||||
"/me",
|
||||
(context: any) => {
|
||||
const user = context.user as User;
|
||||
const tokenPayload = context.tokenPayload as KeycloakTokenPayload;
|
||||
|
||||
if (!user || !tokenPayload) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
keycloakId: user.keycloakId,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
tokenInfo: {
|
||||
sub: tokenPayload.sub,
|
||||
email: tokenPayload.email,
|
||||
name: tokenPayload.name,
|
||||
exp: tokenPayload.exp,
|
||||
iat: tokenPayload.iat,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
response: t.Object({
|
||||
success: t.Literal(true),
|
||||
data: t.Object({
|
||||
user: t.Object({
|
||||
id: t.String(),
|
||||
keycloakId: t.String(),
|
||||
email: t.String(),
|
||||
name: t.String(),
|
||||
createdAt: t.String(),
|
||||
}),
|
||||
tokenInfo: t.Object({
|
||||
sub: t.String(),
|
||||
email: t.Optional(t.String()),
|
||||
name: t.Optional(t.String()),
|
||||
exp: t.Number(),
|
||||
iat: t.Number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
detail: {
|
||||
description: "Get current authenticated user information",
|
||||
security: [{ Bearer: [] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
47
src/modules/auth/service.ts
Normal file
47
src/modules/auth/service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { db } from "@/database/db";
|
||||
import { users } from "@/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { KeycloakTokenPayload } from "@/lib/keycloak";
|
||||
|
||||
/**
|
||||
* Find or create user based on Keycloak token payload
|
||||
* @param payload Keycloak token payload
|
||||
* @returns User record from database
|
||||
*/
|
||||
export async function findOrCreateUser(payload: KeycloakTokenPayload) {
|
||||
// Try to find existing user by keycloakId
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.keycloakId, payload.sub))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return existingUser[0];
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
const newUser = {
|
||||
keycloakId: payload.sub,
|
||||
email: payload.email || "",
|
||||
name: payload.name || payload.preferred_username || "Unknown User",
|
||||
};
|
||||
|
||||
const [createdUser] = await db.insert(users).values(newUser).returning();
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by Keycloak ID
|
||||
* @param keycloakId The Keycloak user ID
|
||||
* @returns User record or null
|
||||
*/
|
||||
export async function getUserByKeycloakId(keycloakId: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.keycloakId, keycloakId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
216
src/modules/customers/controller.ts
Normal file
216
src/modules/customers/controller.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import * as service from "./service";
|
||||
import { CustomerModel } from "./model";
|
||||
|
||||
// Create Elysia instance for customers module
|
||||
export const customers = new Elysia({
|
||||
prefix: "/customers",
|
||||
tags: ["customers"],
|
||||
})
|
||||
.model(CustomerModel)
|
||||
// GET /api/customers/:branch - Get all customers by branch
|
||||
.get(
|
||||
"/:branch",
|
||||
({ params, query }) => {
|
||||
const { branch } = params;
|
||||
const { status } = query as { status?: string };
|
||||
|
||||
const customers = service.getAllCustomers(
|
||||
branch,
|
||||
status as "active" | "inactive" | "pending" | undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customers,
|
||||
count: customers.length,
|
||||
message: `Found ${customers.length} customer(s) for branch: ${branch}`,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
}),
|
||||
query: t.Optional(
|
||||
t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
response: CustomerModel.CustomerList,
|
||||
detail: {
|
||||
description: "Get all customers for a specific branch",
|
||||
parameters: [
|
||||
{
|
||||
name: "branch",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Branch identifier (e.g., branch-01, branch-02, head-office)",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["active", "inactive", "pending"],
|
||||
},
|
||||
description: "Filter customers by status",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
// GET /api/customers/:branch/:id - Get single customer by ID
|
||||
.get(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Get a single customer by ID and branch",
|
||||
},
|
||||
},
|
||||
)
|
||||
// POST /api/customers - Create new customer
|
||||
.post(
|
||||
"/",
|
||||
({ body }) => {
|
||||
const customer = service.createCustomer(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer created successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
body: CustomerModel.CreateCustomer,
|
||||
response: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
description: "Create a new customer",
|
||||
},
|
||||
},
|
||||
)
|
||||
// PUT /api/customers/:branch/:id - Update customer
|
||||
.put(
|
||||
"/:branch/:id",
|
||||
({ params, body }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.updateCustomer(branch, id, body);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer updated successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
body: CustomerModel.UpdateCustomer,
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Update an existing customer",
|
||||
},
|
||||
},
|
||||
)
|
||||
// DELETE /api/customers/:branch/:id - Delete customer
|
||||
.delete(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.deleteCustomer(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer deleted successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Delete a customer",
|
||||
},
|
||||
},
|
||||
);
|
||||
82
src/modules/customers/model.ts
Normal file
82
src/modules/customers/model.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { t } from "elysia";
|
||||
|
||||
// Schemas for validation
|
||||
export const CustomerModel = {
|
||||
Customer: t.Object({
|
||||
id: t.String(),
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String({ format: "email" }),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
createdAt: t.String({ format: "date-time" }),
|
||||
updatedAt: t.String({ format: "date-time" }),
|
||||
}),
|
||||
|
||||
CreateCustomer: t.Object({
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String({ format: "email" }),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
UpdateCustomer: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
email: t.Optional(t.String({ format: "email" })),
|
||||
phone: t.Optional(t.String()),
|
||||
company: t.Optional(t.String()),
|
||||
address: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
CustomerList: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String(),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
createdAt: t.String(),
|
||||
updatedAt: t.String(),
|
||||
}),
|
||||
),
|
||||
count: t.Number(),
|
||||
message: t.Optional(t.String()),
|
||||
}),
|
||||
};
|
||||
|
||||
// Export types from schemas
|
||||
export type Customer = typeof CustomerModel.Customer.static;
|
||||
export type CreateCustomer = typeof CustomerModel.CreateCustomer.static;
|
||||
export type UpdateCustomer = typeof CustomerModel.UpdateCustomer.static;
|
||||
export type CustomerList = typeof CustomerModel.CustomerList.static;
|
||||
109
src/modules/customers/service.ts
Normal file
109
src/modules/customers/service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { getCustomersByBranch, getCustomerById } from "@/lib/mock-data";
|
||||
import type { Customer, CreateCustomer, UpdateCustomer } from "./model";
|
||||
|
||||
/**
|
||||
* Get all customers for a specific branch
|
||||
* @param branch - Branch identifier
|
||||
* @param status - Optional status filter
|
||||
* @returns Array of customers
|
||||
*/
|
||||
export function getAllCustomers(
|
||||
branch: string,
|
||||
status?: "active" | "inactive" | "pending",
|
||||
): Customer[] {
|
||||
let customers = getCustomersByBranch(branch);
|
||||
|
||||
if (status) {
|
||||
customers = customers.filter((customer) => customer.status === status);
|
||||
}
|
||||
|
||||
return customers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single customer by ID and branch
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @returns Customer or undefined if not found
|
||||
*/
|
||||
export function getCustomerByIdAndBranch(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerById(id);
|
||||
|
||||
// Only return if customer belongs to the specified branch
|
||||
if (customer && customer.branch === branch) {
|
||||
return customer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new customer
|
||||
* @param data - Customer creation data
|
||||
* @returns Newly created customer
|
||||
*/
|
||||
export function createCustomer(data: CreateCustomer): Customer {
|
||||
const newCustomer: Customer = {
|
||||
id: `cust-${Date.now()}`,
|
||||
...data,
|
||||
status: data.status || "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would save to database
|
||||
// For now, we'll just return the new customer
|
||||
return newCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing customer
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @param data - Customer update data
|
||||
* @returns Updated customer or undefined if not found
|
||||
*/
|
||||
export function updateCustomer(
|
||||
branch: string,
|
||||
id: string,
|
||||
data: UpdateCustomer,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Merge update data
|
||||
const updatedCustomer: Customer = {
|
||||
...customer,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would update database
|
||||
return updatedCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a customer
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @returns Deleted customer or undefined if not found
|
||||
*/
|
||||
export function deleteCustomer(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// In a real app, this would delete from database
|
||||
return customer;
|
||||
}
|
||||
224
src/modules/quotations/controller.ts
Normal file
224
src/modules/quotations/controller.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import * as service from "./service";
|
||||
import { QuotationModel } from "./model";
|
||||
|
||||
// Create Elysia instance for quotations module
|
||||
export const quotations = new Elysia({
|
||||
prefix: "/quotations",
|
||||
tags: ["quotations"],
|
||||
})
|
||||
.model(QuotationModel)
|
||||
// GET /api/quotations/:branch - Get all quotations by branch
|
||||
.get(
|
||||
"/:branch",
|
||||
({ params, query }) => {
|
||||
const { branch } = params;
|
||||
const { status } = query as { status?: string };
|
||||
|
||||
const quotations = service.getAllQuotations(
|
||||
branch,
|
||||
status as
|
||||
| "draft"
|
||||
| "sent"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotations,
|
||||
count: quotations.length,
|
||||
message: `Found ${quotations.length} quotation(s) for branch: ${branch}`,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
}),
|
||||
query: t.Optional(
|
||||
t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
response: QuotationModel.QuotationList,
|
||||
detail: {
|
||||
description: "Get all quotations for a specific branch",
|
||||
parameters: [
|
||||
{
|
||||
name: "branch",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Branch identifier (e.g., branch-01, branch-02, head-office)",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
},
|
||||
description: "Filter quotations by status",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
// GET /api/quotations/:branch/:id - Get single quotation by ID
|
||||
.get(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Get a single quotation by ID and branch",
|
||||
},
|
||||
},
|
||||
)
|
||||
// POST /api/quotations - Create new quotation
|
||||
.post(
|
||||
"/",
|
||||
({ body }) => {
|
||||
const quotation = service.createQuotation(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation created successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
body: QuotationModel.CreateQuotation,
|
||||
response: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
description: "Create a new quotation",
|
||||
},
|
||||
},
|
||||
)
|
||||
// PUT /api/quotations/:branch/:id - Update quotation
|
||||
.put(
|
||||
"/:branch/:id",
|
||||
({ params, body }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.updateQuotation(branch, id, body);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation updated successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
body: QuotationModel.UpdateQuotation,
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Update an existing quotation",
|
||||
},
|
||||
},
|
||||
)
|
||||
// DELETE /api/quotations/:branch/:id - Delete quotation
|
||||
.delete(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.deleteQuotation(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation deleted successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Delete a quotation",
|
||||
},
|
||||
},
|
||||
);
|
||||
104
src/modules/quotations/model.ts
Normal file
104
src/modules/quotations/model.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { t } from "elysia";
|
||||
|
||||
// Schemas for validation
|
||||
export const QuotationModel = {
|
||||
Quotation: t.Object({
|
||||
id: t.String(),
|
||||
quotationNumber: t.String(),
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String({ format: "date-time" }),
|
||||
validUntil: t.String({ format: "date-time" }),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
taxAmount: t.Number(),
|
||||
totalAmount: t.Number(),
|
||||
status: t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
notes: t.Optional(t.String()),
|
||||
createdAt: t.String({ format: "date-time" }),
|
||||
updatedAt: t.String({ format: "date-time" }),
|
||||
}),
|
||||
|
||||
CreateQuotation: t.Object({
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String({ format: "date-time" }),
|
||||
validUntil: t.String({ format: "date-time" }),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
notes: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
UpdateQuotation: t.Object({
|
||||
customerId: t.Optional(t.String()),
|
||||
customerName: t.Optional(t.String()),
|
||||
date: t.Optional(t.String({ format: "date-time" })),
|
||||
validUntil: t.Optional(t.String({ format: "date-time" })),
|
||||
subtotal: t.Optional(t.Number()),
|
||||
taxRate: t.Optional(t.Number()),
|
||||
notes: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
QuotationList: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
quotationNumber: t.String(),
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String(),
|
||||
validUntil: t.String(),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
taxAmount: t.Number(),
|
||||
totalAmount: t.Number(),
|
||||
status: t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
notes: t.Optional(t.String()),
|
||||
createdAt: t.String(),
|
||||
updatedAt: t.String(),
|
||||
}),
|
||||
),
|
||||
count: t.Number(),
|
||||
message: t.Optional(t.String()),
|
||||
}),
|
||||
};
|
||||
|
||||
// Export types from schemas
|
||||
export type Quotation = typeof QuotationModel.Quotation.static;
|
||||
export type CreateQuotation = typeof QuotationModel.CreateQuotation.static;
|
||||
export type UpdateQuotation = typeof QuotationModel.UpdateQuotation.static;
|
||||
export type QuotationList = typeof QuotationModel.QuotationList.static;
|
||||
222
src/modules/quotations/service.ts
Normal file
222
src/modules/quotations/service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Quotation, CreateQuotation, UpdateQuotation } from "./model";
|
||||
|
||||
// Mock quotations data
|
||||
const mockQuotations: Quotation[] = [
|
||||
{
|
||||
id: "quot-001",
|
||||
quotationNumber: "QT-2024-001",
|
||||
branch: "branch-01",
|
||||
customerId: "cust-001",
|
||||
customerName: "สมชาย ใจดี",
|
||||
date: "2024-01-20T00:00:00Z",
|
||||
validUntil: "2024-02-20T00:00:00Z",
|
||||
subtotal: 50000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 3500,
|
||||
totalAmount: 53500,
|
||||
status: "sent",
|
||||
notes: "Quotation for office supplies",
|
||||
createdAt: "2024-01-20T09:00:00Z",
|
||||
updatedAt: "2024-01-20T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-002",
|
||||
quotationNumber: "QT-2024-002",
|
||||
branch: "branch-01",
|
||||
customerId: "cust-002",
|
||||
customerName: "วิภา สุขสันต์",
|
||||
date: "2024-02-25T00:00:00Z",
|
||||
validUntil: "2024-03-25T00:00:00Z",
|
||||
subtotal: 120000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 8400,
|
||||
totalAmount: 128400,
|
||||
status: "accepted",
|
||||
notes: "Quotation for computer equipment",
|
||||
createdAt: "2024-02-25T10:30:00Z",
|
||||
updatedAt: "2024-02-28T14:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-003",
|
||||
quotationNumber: "QT-2024-003",
|
||||
branch: "branch-02",
|
||||
customerId: "cust-004",
|
||||
customerName: "มานี มีสุข",
|
||||
date: "2024-03-10T00:00:00Z",
|
||||
validUntil: "2024-04-10T00:00:00Z",
|
||||
subtotal: 75000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 5250,
|
||||
totalAmount: 80250,
|
||||
status: "draft",
|
||||
notes: null,
|
||||
createdAt: "2024-03-10T11:00:00Z",
|
||||
updatedAt: "2024-03-10T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-004",
|
||||
quotationNumber: "QT-2024-004",
|
||||
branch: "head-office",
|
||||
customerId: "cust-007",
|
||||
customerName: "ภูมิ รักษ์โลก",
|
||||
date: "2024-04-01T00:00:00Z",
|
||||
validUntil: "2024-05-01T00:00:00Z",
|
||||
subtotal: 200000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 14000,
|
||||
totalAmount: 214000,
|
||||
status: "sent",
|
||||
notes: "Quotation for laboratory equipment",
|
||||
createdAt: "2024-04-01T09:30:00Z",
|
||||
updatedAt: "2024-04-01T09:30:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all quotations for a specific branch
|
||||
* @param branch - Branch identifier
|
||||
* @param status - Optional status filter
|
||||
* @returns Array of quotations
|
||||
*/
|
||||
export function getAllQuotations(
|
||||
branch: string,
|
||||
status?: "draft" | "sent" | "accepted" | "rejected" | "expired",
|
||||
): Quotation[] {
|
||||
let quotations = mockQuotations.filter((q) => q.branch === branch);
|
||||
|
||||
if (status) {
|
||||
quotations = quotations.filter((q) => q.status === status);
|
||||
}
|
||||
|
||||
return quotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single quotation by ID and branch
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @returns Quotation or undefined if not found
|
||||
*/
|
||||
export function getQuotationByIdAndBranch(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Quotation | undefined {
|
||||
const quotation = mockQuotations.find((q) => q.id === id);
|
||||
|
||||
// Only return if quotation belongs to the specified branch
|
||||
if (quotation && quotation.branch === branch) {
|
||||
return quotation;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax and total amounts
|
||||
* @param subtotal - Subtotal amount
|
||||
* @param taxRate - Tax rate (e.g., 0.07 for 7%)
|
||||
* @returns Object with taxAmount and totalAmount
|
||||
*/
|
||||
function calculateTotals(subtotal: number, taxRate: number) {
|
||||
const taxAmount = subtotal * taxRate;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
return { taxAmount, totalAmount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new quotation
|
||||
* @param data - Quotation creation data
|
||||
* @returns Newly created quotation
|
||||
*/
|
||||
export function createQuotation(data: CreateQuotation): Quotation {
|
||||
const { taxAmount, totalAmount } = calculateTotals(
|
||||
data.subtotal,
|
||||
data.taxRate,
|
||||
);
|
||||
|
||||
const newQuotation: Quotation = {
|
||||
id: `quot-${Date.now()}`,
|
||||
quotationNumber: `QT-${new Date().getFullYear()}-${String(mockQuotations.length + 1).padStart(3, "0")}`,
|
||||
...data,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
status: data.status || "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would save to database
|
||||
mockQuotations.push(newQuotation);
|
||||
return newQuotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing quotation
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @param data - Quotation update data
|
||||
* @returns Updated quotation or undefined if not found
|
||||
*/
|
||||
export function updateQuotation(
|
||||
branch: string,
|
||||
id: string,
|
||||
data: UpdateQuotation,
|
||||
): Quotation | undefined {
|
||||
const quotation = getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Recalculate totals if subtotal or taxRate changed
|
||||
let { taxAmount, totalAmount } = quotation;
|
||||
if (data.subtotal !== undefined || data.taxRate !== undefined) {
|
||||
const newSubtotal = data.subtotal ?? quotation.subtotal;
|
||||
const newTaxRate = data.taxRate ?? quotation.taxRate;
|
||||
const calculated = calculateTotals(newSubtotal, newTaxRate);
|
||||
taxAmount = calculated.taxAmount;
|
||||
totalAmount = calculated.totalAmount;
|
||||
}
|
||||
|
||||
// Merge update data
|
||||
const updatedQuotation: Quotation = {
|
||||
...quotation,
|
||||
...data,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would update database
|
||||
const index = mockQuotations.findIndex((q) => q.id === id);
|
||||
if (index !== -1) {
|
||||
mockQuotations[index] = updatedQuotation;
|
||||
}
|
||||
|
||||
return updatedQuotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a quotation
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @returns Deleted quotation or undefined if not found
|
||||
*/
|
||||
export function deleteQuotation(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Quotation | undefined {
|
||||
const quotation = getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// In a real app, this would delete from database
|
||||
const index = mockQuotations.findIndex((q) => q.id === id);
|
||||
if (index !== -1) {
|
||||
mockQuotations.splice(index, 1);
|
||||
}
|
||||
|
||||
return quotation;
|
||||
}
|
||||
Reference in New Issue
Block a user