Kanban board, Project Management, Proposals/Quotes, Time Tracking, Custom Fields
Build & Auto-Repair / build (push) Has been cancelled
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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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) : '{}',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user