Kanban board, Project Management, Proposals/Quotes, Time Tracking, Custom Fields
Build & Auto-Repair / build (push) Has been cancelled

- Kanban board with drag-and-drop per stage, colour-coded cards, score bars
- Project Management with milestones, Gantt timeline, inline add milestone/task
- Proposal/Quote builder with line items, VAT, terms, e-signature capture
- Time Tracking with stopwatch, manual entry, hours-by-project chart
- Custom Fields system: admin-defined fields per entity (leads/customers/
  projects/opportunities) with drag-and-drop reordering in settings
- Reusable CustomFieldsSection component for entity detail pages
- Customers API endpoint
- All builds pass with zero errors
This commit is contained in:
2026-07-01 11:18:59 +02:00
parent 5d971dc749
commit 545065b527
38 changed files with 3770 additions and 4 deletions
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const entityType = searchParams.get("entityType")
let sql = `SELECT id, entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order, is_active, created_at
FROM custom_field_definitions WHERE is_active = true`
const params: any[] = []
if (entityType) {
sql += ` AND entity_type = $1`
params.push(entityType)
}
sql += ` ORDER BY entity_type, sort_order`
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Custom fields API error:", error)
return NextResponse.json({ error: "Failed to load custom fields" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
if (!body.entityType || !body.fieldKey || !body.fieldLabel) {
return NextResponse.json({ error: "entityType, fieldKey, fieldLabel are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[
body.entityType,
body.fieldKey,
body.fieldLabel,
body.fieldType || "text",
body.options ? JSON.stringify(body.options) : null,
body.placeholder || null,
body.defaultValue || null,
body.section || null,
body.isRequired || false,
body.sortOrder || 0,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
if (error?.constraint === "uq_custom_field_entity_key") {
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
}
console.error("Custom fields POST error:", error)
return NextResponse.json({ error: "Failed to create custom field" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
const fields: string[] = []
const values: any[] = []
let idx = 1
const fieldMap: Record<string, string> = {
fieldLabel: "field_label",
fieldType: "field_type",
options: "options",
placeholder: "placeholder",
defaultValue: "default_value",
section: "section",
isRequired: "is_required",
sortOrder: "sort_order",
isActive: "is_active",
}
for (const [key, col] of Object.entries(fieldMap)) {
if (body[key] !== undefined) {
fields.push(`${col} = $${idx++}`)
values.push(key === "options" && body[key] ? JSON.stringify(body[key]) : body[key])
}
}
if (fields.length === 0) return NextResponse.json({ error: "No fields to update" }, { status: 400 })
fields.push(`updated_at = NOW()`)
values.push(body.id)
await query(`UPDATE custom_field_definitions SET ${fields.join(", ")} WHERE id = $${idx}`, values)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Custom fields PATCH error:", error)
return NextResponse.json({ error: "Failed to update custom field" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
await getSessionUser()
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
await query("DELETE FROM custom_field_definitions WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Custom fields DELETE error:", error)
return NextResponse.json({ error: "Failed to delete custom field" }, { status: 500 })
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const result = await query(
`SELECT c.id, c.customer_type,
ic.first_name, ic.last_name,
cc.company_name
FROM customers c
LEFT JOIN individual_customers ic ON ic.customer_id = c.id
LEFT JOIN company_customers cc ON cc.customer_id = c.id
WHERE c.deleted_at IS NULL
ORDER BY COALESCE(cc.company_name, ic.last_name || ', ' || ic.first_name)
LIMIT $1`,
[limit]
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Customers API error:", error)
return NextResponse.json({ error: "Failed to load customers" }, { status: 500 })
}
}
+6
View File
@@ -28,6 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
l.custom_fields,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
@@ -59,6 +60,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
customFields: r.custom_fields || {},
createdAt: r.created_at,
updatedAt: r.updated_at,
}
@@ -135,6 +137,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
fields.push(`score = $${idx++}`)
values.push(score)
}
if (body.customFields !== undefined) {
fields.push(`custom_fields = $${idx++}`)
values.push(JSON.stringify(body.customFields))
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
function stageStatus(name: string): string {
switch (name) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const result = await query(
`SELECT ls.id, ls.name, ls.sort_order, ls.probability,
COALESCE(
json_agg(
json_build_object(
'id', l.id,
'company_name', l.company_name,
'contact_name', l.contact_name,
'email', l.email,
'phone', l.phone,
'score', l.score,
'notes', l.notes,
'assigned_to', l.assigned_to,
'created_at', l.created_at
)
ORDER BY l.created_at DESC
) FILTER (WHERE l.id IS NOT NULL),
'[]'::json
) AS leads
FROM lead_stages ls
LEFT JOIN leads l ON l.stage_id = ls.id AND l.deleted_at IS NULL
WHERE ls.is_active = true
GROUP BY ls.id, ls.name, ls.sort_order, ls.probability
ORDER BY ls.sort_order`
)
const columns = result.rows.map((r: any) => {
const status = stageStatus(r.name)
let leads = r.leads || []
if (!isAdmin) {
leads = leads.filter((l: any) => l.assigned_to === user.id)
}
return {
id: r.id,
name: r.name,
status,
sortOrder: r.sort_order,
probability: r.probability,
leads,
}
})
return NextResponse.json(columns)
} catch (error) {
console.error("Kanban API error:", error)
return NextResponse.json({ error: "Failed to load kanban data" }, { status: 500 })
}
}
+3 -2
View File
@@ -140,8 +140,8 @@ export async function POST(request: NextRequest) {
const stageId = stageResult.rows[0]?.id || 1
const result = await query(
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, custom_fields, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING id`,
[
body.companyName,
@@ -152,6 +152,7 @@ export async function POST(request: NextRequest) {
body.source || null,
stageId,
assignedUserId,
body.customFields ? JSON.stringify(body.customFields) : '{}',
]
)
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
if (body.startDate !== undefined) { fields.push(`start_date = $${idx++}`); values.push(body.startDate) }
if (body.dueDate !== undefined) { fields.push(`due_date = $${idx++}`); values.push(body.dueDate) }
if (body.status === "completed") {
fields.push(`completed_at = NOW()`)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE project_milestones SET ${fields.join(", ")} WHERE id = $${idx} RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Milestone not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Milestone PATCH error:", error)
return NextResponse.json({ error: "Failed to update milestone" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
await query("DELETE FROM project_milestones WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Milestone DELETE error:", error)
return NextResponse.json({ error: "Failed to delete milestone" }, { status: 500 })
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
"SELECT id, name, color, sort_order FROM project_statuses WHERE is_active = true ORDER BY sort_order"
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Project statuses API error:", error)
return NextResponse.json({ error: "Failed to load statuses" }, { status: 500 })
}
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT pm.id, pm.name, pm.description, pm.sort_order, pm.status, pm.start_date, pm.due_date, pm.completed_at, pm.created_at,
COALESCE(
json_agg(
json_build_object(
'id', t.id,
'title', t.title,
'description', t.description,
'status', t.status,
'due_date', t.due_date,
'completed_at', t.completed_at,
'assigned_to', t.assigned_to,
'created_at', t.created_at
)
ORDER BY t.created_at DESC
) FILTER (WHERE t.id IS NOT NULL),
'[]'::json
) AS tasks
FROM project_milestones pm
LEFT JOIN tasks t ON t.milestone_id = pm.id AND t.deleted_at IS NULL
WHERE pm.project_id = $1
GROUP BY pm.id, pm.name, pm.description, pm.sort_order, pm.status, pm.start_date, pm.due_date, pm.completed_at, pm.created_at
ORDER BY pm.sort_order`,
[id]
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Milestones API error:", error)
return NextResponse.json({ error: "Failed to load milestones" }, { status: 500 })
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const maxOrder = await query(
"SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM project_milestones WHERE project_id = $1",
[id]
)
const result = await query(
`INSERT INTO project_milestones (project_id, name, description, sort_order, start_date, due_date)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[id, body.name, body.description || null, maxOrder.rows[0].next_order, body.startDate || null, body.dueDate || null]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Milestones POST error:", error)
return NextResponse.json({ error: "Failed to create milestone" }, { status: 500 })
}
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date, p.actual_end_date, p.budget, p.custom_fields, p.created_at,
ps.id AS status_id, ps.name AS status_name, ps.color AS status_color,
c.id AS customer_id, c.company_name,
u.id AS owner_id, u.first_name || ' ' || u.last_name AS owner_name, u.avatar_url AS owner_avatar,
opp.id AS opportunity_id, opp.name AS opportunity_name
FROM projects p
JOIN project_statuses ps ON ps.id = p.status_id
JOIN customers c ON c.id = p.customer_id
JOIN users u ON u.id = p.owner_id
LEFT JOIN opportunities opp ON opp.id = p.opportunity_id
WHERE p.id = $1 AND p.deleted_at IS NULL`,
[id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 })
}
return NextResponse.json(result.rows[0])
} catch (error) {
console.error("Project detail API error:", error)
return NextResponse.json({ error: "Failed to load project" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
if (body.statusId !== undefined) { fields.push(`status_id = $${idx++}`); values.push(body.statusId) }
if (body.startDate !== undefined) { fields.push(`start_date = $${idx++}`); values.push(body.startDate) }
if (body.targetEndDate !== undefined) { fields.push(`target_end_date = $${idx++}`); values.push(body.targetEndDate) }
if (body.actualEndDate !== undefined) { fields.push(`actual_end_date = $${idx++}`); values.push(body.actualEndDate) }
if (body.budget !== undefined) { fields.push(`budget = $${idx++}`); values.push(body.budget) }
if (body.ownerId !== undefined) { fields.push(`owner_id = $${idx++}`); values.push(body.ownerId) }
if (body.customFields !== undefined) { fields.push(`custom_fields = $${idx++}`); values.push(JSON.stringify(body.customFields)) }
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE projects SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Project PATCH error:", error)
return NextResponse.json({ error: "Failed to update project" }, { status: 500 })
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url)
const search = searchParams.get("search") || ""
const statusId = searchParams.get("status") || ""
let sql = `SELECT p.id, p.name, p.description, p.start_date, p.target_end_date, p.actual_end_date, p.budget, p.created_at,
ps.name AS status_name, ps.color AS status_color,
c.id AS customer_id, c.company_name,
u.id AS owner_id, u.first_name || ' ' || u.last_name AS owner_name, u.avatar_url AS owner_avatar
FROM projects p
JOIN project_statuses ps ON ps.id = p.status_id
JOIN customers c ON c.id = p.customer_id
JOIN users u ON u.id = p.owner_id
WHERE p.deleted_at IS NULL`
const params: any[] = []
let idx = 1
if (!isAdmin) {
sql += ` AND (p.owner_id = $${idx} OR p.created_by = $${idx})`
params.push(user.id)
idx++
}
if (search) {
sql += ` AND (p.name ILIKE $${idx} OR c.company_name ILIKE $${idx})`
params.push(`%${search}%`)
idx++
}
if (statusId) {
sql += ` AND p.status_id = $${idx}`
params.push(statusId)
idx++
}
sql += ` ORDER BY p.created_at DESC`
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Projects API error:", error)
return NextResponse.json({ error: "Failed to load projects" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const planningStatus = await query("SELECT id FROM project_statuses WHERE name = 'Planning'")
const statusId = planningStatus.rows[0]?.id
const result = await query(
`INSERT INTO projects (customer_id, owner_id, name, description, status_id, start_date, target_end_date, budget, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
[
body.customerId,
body.ownerId || user.id,
body.name,
body.description || null,
statusId,
body.startDate || null,
body.targetEndDate || null,
body.budget || null,
user.id,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Projects POST error:", error)
return NextResponse.json({ error: "Failed to create project" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
await query("UPDATE projects SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Projects DELETE error:", error)
return NextResponse.json({ error: "Failed to delete project" }, { status: 500 })
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query("SELECT id, name, color, sort_order FROM proposal_statuses ORDER BY sort_order")
return NextResponse.json(result.rows)
} catch (error) {
console.error("Proposal statuses API error:", error)
return NextResponse.json({ error: "Failed to load statuses" }, { status: 500 })
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const maxOrder = await query(
"SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM proposal_items WHERE proposal_id = $1",
[id]
)
const quantity = body.quantity || 1
const unitPrice = body.unitPrice || 0
const discountPercent = body.discountPercent || 0
const totalPrice = quantity * unitPrice * (1 - discountPercent / 100)
const result = await query(
`INSERT INTO proposal_items (proposal_id, product_id, description, quantity, unit_price, discount_percent, total_price, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[id, body.productId || null, body.description, quantity, unitPrice, discountPercent, totalPrice, maxOrder.rows[0].next_order]
)
await recalcProposal(id)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Proposal items POST error:", error)
return NextResponse.json({ error: "Failed to add item" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
await getSessionUser()
const { id } = await params
const { searchParams } = new URL(request.url)
const itemId = searchParams.get("itemId")
if (!itemId) return NextResponse.json({ error: "itemId required" }, { status: 400 })
await query("DELETE FROM proposal_items WHERE id = $1 AND proposal_id = $2", [itemId, id])
await recalcProposal(id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Proposal items DELETE error:", error)
return NextResponse.json({ error: "Failed to delete item" }, { status: 500 })
}
}
async function recalcProposal(proposalId: string) {
const items = await query(
"SELECT COALESCE(SUM(total_price), 0) AS subtotal FROM proposal_items WHERE proposal_id = $1",
[proposalId]
)
const subtotal = parseFloat(items.rows[0].subtotal)
const prop = await query("SELECT tax_percent FROM proposals WHERE id = $1", [proposalId])
const taxPercent = parseFloat(prop.rows[0]?.tax_percent || 15)
const taxAmount = subtotal * (taxPercent / 100)
const totalAmount = subtotal + taxAmount
await query(
`UPDATE proposals SET subtotal = $1, tax_amount = $2, total_amount = $3, updated_at = NOW() WHERE id = $4`,
[subtotal, taxAmount, totalAmount, proposalId]
)
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const [propResult, itemsResult, sigResult] = await Promise.all([
query(
`SELECT p.id, p.title, p.proposal_number, p.subtotal, p.tax_percent, p.tax_amount, p.total_amount,
p.valid_until, p.notes, p.terms, p.content, p.created_at, p.updated_at,
ps.id AS status_id, ps.name AS status_name, ps.color AS status_color,
u.first_name || ' ' || u.last_name AS created_by_name,
l.id AS lead_id, l.company_name AS lead_company, l.contact_name AS lead_contact,
c.id AS customer_id, c.company_name
FROM proposals p
JOIN proposal_statuses ps ON ps.id = p.status_id
JOIN users u ON u.id = p.created_by
LEFT JOIN leads l ON l.id = p.lead_id
LEFT JOIN customers c ON c.id = p.customer_id
WHERE p.id = $1 AND p.deleted_at IS NULL`,
[id]
),
query(
`SELECT id, product_id, description, quantity, unit_price, discount_percent, total_price, sort_order
FROM proposal_items WHERE proposal_id = $1 ORDER BY sort_order`,
[id]
),
query(
`SELECT id, signatory_name, signatory_email, signed_at FROM proposal_signatures WHERE proposal_id = $1 AND is_valid = true ORDER BY signed_at DESC`,
[id]
),
])
if (propResult.rows.length === 0) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 })
}
return NextResponse.json({
...propResult.rows[0],
items: itemsResult.rows,
signatures: sigResult.rows,
})
} catch (error) {
console.error("Proposal detail API error:", error)
return NextResponse.json({ error: "Failed to load proposal" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
const fieldMap: Record<string, string> = {
title: "title",
statusId: "status_id",
content: "content",
subtotal: "subtotal",
taxPercent: "tax_percent",
taxAmount: "tax_amount",
totalAmount: "total_amount",
validUntil: "valid_until",
notes: "notes",
terms: "terms",
leadId: "lead_id",
customerId: "customer_id",
opportunityId: "opportunity_id",
}
for (const [key, col] of Object.entries(fieldMap)) {
if (body[key] !== undefined) {
fields.push(`${col} = $${idx++}`)
values.push(body[key])
}
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE proposals SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL`,
values
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Proposal PATCH error:", error)
return NextResponse.json({ error: "Failed to update proposal" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
await getSessionUser()
const { id } = await params
await query("UPDATE proposals SET deleted_at = NOW() WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Proposal DELETE error:", error)
return NextResponse.json({ error: "Failed to delete proposal" }, { status: 500 })
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const signature = await query(
`INSERT INTO proposal_signatures (proposal_id, signatory_name, signatory_email, signature_data, ip_address)
VALUES ($1, $2, $3, $4, $5::inet) RETURNING id`,
[
id,
body.name,
body.email,
body.signatureData || null,
request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "127.0.0.1",
]
)
const acceptedStatus = await query("SELECT id FROM proposal_statuses WHERE name = 'Accepted'")
if (acceptedStatus.rows[0]) {
await query(
"UPDATE proposals SET status_id = $1, updated_at = NOW() WHERE id = $2",
[acceptedStatus.rows[0].id, id]
)
}
return NextResponse.json({ success: true, id: signature.rows[0].id })
} catch (error) {
console.error("Proposal sign error:", error)
return NextResponse.json({ error: "Failed to sign proposal" }, { status: 500 })
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const search = searchParams.get("search") || ""
const statusId = searchParams.get("status") || ""
let sql = `SELECT p.id, p.title, p.proposal_number, p.subtotal, p.total_amount, p.valid_until, p.created_at,
ps.name AS status_name, ps.color AS status_color,
u.first_name || ' ' || u.last_name AS created_by_name
FROM proposals p
JOIN proposal_statuses ps ON ps.id = p.status_id
JOIN users u ON u.id = p.created_by
WHERE p.deleted_at IS NULL`
const params: any[] = []
let idx = 1
if (search) {
sql += ` AND (p.title ILIKE $${idx} OR p.proposal_number ILIKE $${idx})`
params.push(`%${search}%`)
idx++
}
if (statusId) {
sql += ` AND p.status_id = $${idx}`
params.push(statusId)
idx++
}
sql += ` ORDER BY p.created_at DESC LIMIT 50`
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Proposals API error:", error)
return NextResponse.json({ error: "Failed to load proposals" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const draftStatus = await query("SELECT id FROM proposal_statuses WHERE name = 'Draft'")
const statusId = draftStatus.rows[0]?.id
const numResult = await query("SELECT COALESCE(MAX(SUBSTRING(proposal_number FROM 'Q-(\\d+)')::INT), 0) + 1 AS next_num FROM proposals WHERE deleted_at IS NULL")
const nextNum = numResult.rows[0]?.next_num || 1
const proposalNumber = `Q-${String(nextNum).padStart(4, "0")}`
const result = await query(
`INSERT INTO proposals (lead_id, customer_id, title, proposal_number, status_id, content, subtotal, tax_percent, tax_amount, total_amount, valid_until, notes, terms, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`,
[
body.leadId || null,
body.customerId || null,
body.title,
proposalNumber,
statusId,
body.content ? JSON.stringify(body.content) : null,
body.subtotal || 0,
body.taxPercent !== undefined ? body.taxPercent : 15,
body.taxAmount || 0,
body.totalAmount || 0,
body.validUntil || null,
body.notes || null,
body.terms || null,
user.id,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id, proposalNumber }, { status: 201 })
} catch (error) {
console.error("Proposals POST error:", error)
return NextResponse.json({ error: "Failed to create proposal" }, { status: 500 })
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const result = await query(
`INSERT INTO tasks (title, description, status, project_id, milestone_id, assigned_to, assigned_by, created_at, updated_at)
VALUES ($1, $2, 'pending', $3, $4, $5, $6, NOW(), NOW()) RETURNING id`,
[
body.title,
body.description || null,
body.projectId || null,
body.milestoneId || null,
body.assignedTo || null,
user.id,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Tasks POST error:", error)
return NextResponse.json({ error: "Failed to create task" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const { id, ...fields } = body
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
const setClauses: string[] = []
const values: any[] = []
let idx = 1
if (fields.status !== undefined) { setClauses.push(`status = $${idx++}`); values.push(fields.status) }
if (fields.title !== undefined) { setClauses.push(`title = $${idx++}`); values.push(fields.title) }
if (fields.assignedTo !== undefined) { setClauses.push(`assigned_to = $${idx++}`); values.push(fields.assignedTo) }
if (fields.status === "completed") {
setClauses.push(`completed_at = NOW()`)
}
if (setClauses.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
setClauses.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE tasks SET ${setClauses.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL`,
values
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Tasks PATCH error:", error)
return NextResponse.json({ error: "Failed to update task" }, { status: 500 })
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const projectId = searchParams.get("projectId") || ""
const userId = searchParams.get("userId") || ""
const from = searchParams.get("from") || ""
const to = searchParams.get("to") || ""
let sql = `SELECT te.id, te.description, te.date, te.duration_minutes, te.billable, te.hourly_rate, te.created_at,
u.first_name || ' ' || u.last_name AS user_name,
p.name AS project_name
FROM time_entries te
JOIN users u ON u.id = te.user_id
LEFT JOIN projects p ON p.id = te.project_id`
const conditions: string[] = ["te.user_id IS NOT NULL"]
const params: any[] = []
let idx = 1
if (!(user.role === "admin" || user.role === "super_admin")) {
conditions.push(`te.user_id = $${idx++}`)
params.push(user.id)
} else if (userId) {
conditions.push(`te.user_id = $${idx++}`)
params.push(userId)
}
if (projectId) {
conditions.push(`te.project_id = $${idx++}`)
params.push(projectId)
}
if (from) { conditions.push(`te.date >= $${idx++}`); params.push(from) }
if (to) { conditions.push(`te.date <= $${idx++}`); params.push(to) }
sql += ` WHERE ${conditions.join(" AND ")} ORDER BY te.date DESC, te.created_at DESC LIMIT 100`
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Time entries API error:", error)
return NextResponse.json({ error: "Failed to load time entries" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const duration = body.durationMinutes || 0
if (duration <= 0) {
return NextResponse.json({ error: "Duration must be greater than 0" }, { status: 400 })
}
const result = await query(
`INSERT INTO time_entries (user_id, project_id, milestone_id, task_id, description, date, start_time, end_time, duration_minutes, billable, hourly_rate)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
[
body.userId || user.id,
body.projectId || null,
body.milestoneId || null,
body.taskId || null,
body.description || null,
body.date || new Date().toISOString().split("T")[0],
body.startTime || null,
body.endTime || null,
duration,
body.billable !== false,
body.hourlyRate || null,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Time entries POST error:", error)
return NextResponse.json({ error: "Failed to create time entry" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
await getSessionUser()
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
await query("DELETE FROM time_entries WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Time entries DELETE error:", error)
return NextResponse.json({ error: "Failed to delete entry" }, { status: 500 })
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const userFilter = isAdmin ? "" : "WHERE te.user_id = $1"
const params = isAdmin ? [] : [user.id]
const [totals, perProject, perUser] = await Promise.all([
query(
`SELECT COALESCE(SUM(duration_minutes), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END), 0) AS billable_minutes,
COALESCE(SUM(duration_minutes * hourly_rate / 60), 0) AS total_revenue,
COUNT(*) AS entry_count
FROM time_entries te ${userFilter}`,
params
),
query(
`SELECT p.id, p.name,
COALESCE(SUM(te.duration_minutes), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN te.billable THEN te.duration_minutes ELSE 0 END), 0) AS billable_minutes,
COALESCE(SUM(te.duration_minutes * te.hourly_rate / 60), 0) AS revenue
FROM time_entries te
LEFT JOIN projects p ON p.id = te.project_id
${userFilter ? userFilter + " AND " : "WHERE "} te.project_id IS NOT NULL
GROUP BY p.id, p.name
ORDER BY total_minutes DESC
LIMIT 10`,
params
),
isAdmin
? query(
`SELECT u.id, u.first_name || ' ' || u.last_name AS name,
COALESCE(SUM(te.duration_minutes), 0) AS total_minutes,
COALESCE(SUM(te.duration_minutes * te.hourly_rate / 60), 0) AS revenue
FROM time_entries te
JOIN users u ON u.id = te.user_id
GROUP BY u.id, u.first_name, u.last_name
ORDER BY total_minutes DESC`
)
: Promise.resolve({ rows: [] }),
])
return NextResponse.json({
totals: totals.rows[0],
perProject: perProject.rows,
perUser: perUser.rows,
})
} catch (error) {
console.error("Time summary API error:", error)
return NextResponse.json({ error: "Failed to load summary" }, { status: 500 })
}
}