Compare commits
2 Commits
48f78cf2ca
...
72b839f5d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b839f5d4 | |||
| cb1944a9ea |
@@ -85,5 +85,8 @@ BEGIN;
|
|||||||
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
|
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
|
||||||
\i 022_custom_fields.sql
|
\i 022_custom_fields.sql
|
||||||
|
|
||||||
|
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
|
||||||
|
\i ../../src/app/client-portal/database/020_client_portal.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
Generated
-3
@@ -248,7 +248,6 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -263,7 +262,6 @@
|
|||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
@@ -277,7 +275,6 @@
|
|||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export default function ProjectsPage() {
|
|||||||
if (search) params.set("search", search)
|
if (search) params.set("search", search)
|
||||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||||
const res = await fetch(`/api/projects?${params}`)
|
const res = await fetch(`/api/projects?${params}`)
|
||||||
if (res.ok) setProjects(await res.json()); else setProjects([])
|
const data = res.ok ? await res.json() : []
|
||||||
|
setProjects(Array.isArray(data) ? data : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load projects", e)
|
console.error("Failed to load projects", e)
|
||||||
setProjects([])
|
setProjects([])
|
||||||
@@ -74,7 +75,7 @@ export default function ProjectsPage() {
|
|||||||
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/project-statuses").then(r => r.json()).then(setStatuses).catch(() => {})
|
fetch("/api/project-statuses").then(r => r.json()).then((d) => setStatuses(Array.isArray(d) ? d : [])).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
|
||||||
@@ -167,7 +168,7 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
fetch("/api/customers?limit=100").then(r => r.json()).then(setCustomers).catch(() => {})
|
fetch("/api/customers?limit=100").then(r => r.json()).then(d => setCustomers(Array.isArray(d) ? d : [])).catch(() => {})
|
||||||
fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch(() => {})
|
fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch(() => {})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ export default function TimeTrackingPage() {
|
|||||||
fetch("/api/time-entries/summary"),
|
fetch("/api/time-entries/summary"),
|
||||||
fetch("/api/projects"),
|
fetch("/api/projects"),
|
||||||
])
|
])
|
||||||
if (eRes.ok) setEntries(await eRes.json()); else setEntries([])
|
const eData = eRes.ok ? await eRes.json() : []
|
||||||
if (sRes.ok) setSummary(await sRes.json()); else setSummary(null)
|
const sData = sRes.ok ? await sRes.json() : null
|
||||||
if (pRes.ok) setProjects(await pRes.json()); else setProjects([])
|
const pData = pRes.ok ? await pRes.json() : []
|
||||||
|
setEntries(Array.isArray(eData) ? eData : [])
|
||||||
|
setSummary(sData?.totals ? sData : null)
|
||||||
|
setProjects(Array.isArray(pData) ? pData : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load time data", e)
|
console.error("Failed to load time data", e)
|
||||||
setEntries([])
|
setEntries([])
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function ClientActivity() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute left-[17px] top-2 bottom-2 w-px bg-[#1a1a24]" />
|
<div className="absolute left-[17px] top-2 bottom-2 w-px bg-[#1a1a24]" />
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{events.map((e, i) => (
|
{(events || []).map((e, i) => (
|
||||||
<button
|
<button
|
||||||
key={`${e.event_type}-${e.id}-${i}`}
|
key={`${e.event_type}-${e.id}-${i}`}
|
||||||
onClick={() => handleClick(e)}
|
onClick={() => handleClick(e)}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ export async function GET() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const events = [
|
const events = [
|
||||||
...milestones.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
...(milestones.rows || []).map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
||||||
...invoices.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
...(invoices.rows || []).map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
||||||
...tickets.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.created_at })),
|
...(tickets.rows || []).map((r: Record<string, unknown>) => ({ ...r, date: r.created_at })),
|
||||||
].sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()).slice(0, 30)
|
].sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()).slice(0, 30)
|
||||||
|
|
||||||
return NextResponse.json({ events })
|
return NextResponse.json({ events })
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await query(
|
const project = await query(
|
||||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date as end_date,
|
||||||
created_at, updated_at
|
p.budget, p.created_at, p.updated_at, s.name as status_name, s.color as status_color
|
||||||
FROM projects
|
FROM projects p
|
||||||
WHERE id = $1 AND customer_id = $2 AND deleted_at IS NULL`,
|
LEFT JOIN project_statuses s ON s.id = p.status_id
|
||||||
|
WHERE p.id = $1 AND p.customer_id = $2 AND p.deleted_at IS NULL`,
|
||||||
[id, session.customerId],
|
[id, session.customerId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date as end_date,
|
||||||
created_at, updated_at
|
p.budget, p.created_at, p.updated_at, s.name as status_name, s.color as status_color
|
||||||
FROM projects
|
FROM projects p
|
||||||
WHERE customer_id = $1 AND deleted_at IS NULL
|
LEFT JOIN project_statuses s ON s.id = p.status_id
|
||||||
ORDER BY created_at DESC`,
|
WHERE p.customer_id = $1 AND p.deleted_at IS NULL
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
[session.customerId],
|
[session.customerId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export default function InvoiceDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" })
|
fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setData)
|
.then((d) => {
|
||||||
|
if (d.invoice) setData({ ...d, items: d.items || [], receipts: d.receipts || [] })
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ export default function InvoiceDetail() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, i) => (
|
{(items || []).map((item, i) => (
|
||||||
<tr key={i} className="border-b border-[#1a1a24]/50">
|
<tr key={i} className="border-b border-[#1a1a24]/50">
|
||||||
<td className="text-sm text-white px-4 py-3">{item.description}</td>
|
<td className="text-sm text-white px-4 py-3">{item.description}</td>
|
||||||
<td className="text-sm text-white px-4 py-3 text-right">{item.quantity}</td>
|
<td className="text-sm text-white px-4 py-3 text-right">{item.quantity}</td>
|
||||||
@@ -116,7 +118,7 @@ export default function InvoiceDetail() {
|
|||||||
<>
|
<>
|
||||||
<h2 className="text-base font-semibold text-white mb-3">Receipts</h2>
|
<h2 className="text-base font-semibold text-white mb-3">Receipts</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{receipts.map((r) => (
|
{(receipts || []).map((r) => (
|
||||||
<div key={r.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4 flex items-center justify-between">
|
<div key={r.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-4 w-4 text-[#1BB0CE]" />
|
<FileText className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function ClientInvoices() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoices.map((inv) => (
|
{(invoices || []).map((inv) => (
|
||||||
<button
|
<button
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
onClick={() => router.push(`/client-portal/invoices/${inv.id}`)}
|
onClick={() => router.push(`/client-portal/invoices/${inv.id}`)}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function ClientProfile() {
|
|||||||
|
|
||||||
{/* Contacts sidebar */}
|
{/* Contacts sidebar */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{contacts.map((c, i) => (
|
{(contacts || []).map((c, i) => (
|
||||||
<div key={i} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
<div key={i} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{c.type === "email" ? <Mail className="h-4 w-4 text-[#1BB0CE]" /> :
|
{c.type === "email" ? <Mail className="h-4 w-4 text-[#1BB0CE]" /> :
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export default function ProjectDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/client-portal/api/projects/${id}`, { credentials: "include" })
|
fetch(`/client-portal/api/projects/${id}`, { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setData)
|
.then((d) => {
|
||||||
|
if (d.project) setData({ ...d, milestones: d.milestones || [], deliverables: d.deliverables || [] })
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -60,10 +62,10 @@ export default function ProjectDetail() {
|
|||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
milestones: prev.milestones.map((m) =>
|
milestones: (prev.milestones || []).map((m) =>
|
||||||
m.id === milestoneId ? { ...m, ...updated.milestone } : m,
|
m.id === milestoneId ? { ...m, ...updated.milestone } : m,
|
||||||
),
|
),
|
||||||
deliverables: prev.deliverables.map((d) =>
|
deliverables: (prev.deliverables || []).map((d) =>
|
||||||
d.milestone_id === milestoneId
|
d.milestone_id === milestoneId
|
||||||
? { ...d, status: action === "approve" ? "approved" : "rejected" }
|
? { ...d, status: action === "approve" ? "approved" : "rejected" }
|
||||||
: d,
|
: d,
|
||||||
@@ -120,7 +122,7 @@ export default function ProjectDetail() {
|
|||||||
{milestones.length === 0 ? (
|
{milestones.length === 0 ? (
|
||||||
<p className="text-sm text-[#6a6a75]">No milestones yet</p>
|
<p className="text-sm text-[#6a6a75]">No milestones yet</p>
|
||||||
) : (
|
) : (
|
||||||
milestones.map((m) => {
|
(milestones || []).map((m) => {
|
||||||
const si = statusIcon[m.status] || { icon: Clock, color: "#6a6a75" }
|
const si = statusIcon[m.status] || { icon: Clock, color: "#6a6a75" }
|
||||||
const Icon = si.icon
|
const Icon = si.icon
|
||||||
const milestoneDeliverables = deliverables.filter((d) => d.milestone_id === m.id)
|
const milestoneDeliverables = deliverables.filter((d) => d.milestone_id === m.id)
|
||||||
@@ -163,7 +165,7 @@ export default function ProjectDetail() {
|
|||||||
{milestoneDeliverables.length > 0 && (
|
{milestoneDeliverables.length > 0 && (
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-2 mb-3">
|
||||||
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Deliverables</span>
|
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Deliverables</span>
|
||||||
{milestoneDeliverables.map((d) => (
|
{(milestoneDeliverables || []).map((d) => (
|
||||||
<div key={d.id} className="flex items-center justify-between bg-[#1a1a24] rounded-lg px-3 py-2">
|
<div key={d.id} className="flex items-center justify-between bg-[#1a1a24] rounded-lg px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-3.5 w-3.5 text-[#6a6a75]" />
|
<FileText className="h-3.5 w-3.5 text-[#6a6a75]" />
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ interface Project {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
status: string
|
status_name: string
|
||||||
|
status_color: string
|
||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
}
|
budget: number
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
active: "bg-emerald-500/10 text-emerald-400",
|
|
||||||
on_hold: "bg-amber-500/10 text-amber-400",
|
|
||||||
completed: "bg-blue-500/10 text-blue-400",
|
|
||||||
cancelled: "bg-red-500/10 text-red-400",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientProjects() {
|
export default function ClientProjects() {
|
||||||
@@ -43,7 +38,7 @@ export default function ClientProjects() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{projects.map((p) => (
|
{(projects || []).map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => router.push(`/client-portal/projects/${p.id}`)}
|
onClick={() => router.push(`/client-portal/projects/${p.id}`)}
|
||||||
@@ -53,8 +48,8 @@ export default function ClientProjects() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<h3 className="text-white font-semibold">{p.name}</h3>
|
<h3 className="text-white font-semibold">{p.name}</h3>
|
||||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${statusColors[p.status] || "bg-zinc-500/10 text-zinc-400"}`}>
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: `${p.status_color}15`, color: p.status_color }}>
|
||||||
{p.status.replace("_", " ")}
|
{(p.status_name || "unknown").replace("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{p.description && (
|
{p.description && (
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export default function ClientSupport() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tickets.map((t) => (
|
{(tickets || []).map((t) => (
|
||||||
<div key={t.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
<div key={t.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<h3 className="text-white text-sm font-medium">{t.title}</h3>
|
<h3 className="text-white text-sm font-medium">{t.title}</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user