Fixed Projects/Proposals/Time

This commit is contained in:
JCBSComputer
2026-07-01 12:08:22 +02:00
parent b75351112e
commit cb1944a9ea
14 changed files with 50 additions and 45 deletions
+3
View File
@@ -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;
-3
View File
@@ -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"
}, },
+5 -4
View File
@@ -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}`)
setProjects(await res.json()) const data = 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)
} finally { } finally {
@@ -73,7 +74,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() : "—"
@@ -166,8 +167,8 @@ 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 : (d.customers || d.rows || []))).catch(() => {})
fetch("/api/users").then(r => r.json()).then(setUsers).catch(() => {}) fetch("/api/users").then(r => r.json()).then((d) => setUsers(Array.isArray(d) ? d : (d.users || d.rows || []))).catch(() => {})
}, [open]) }, [open])
const handleSubmit = async () => { const handleSubmit = async () => {
+6 -3
View File
@@ -52,9 +52,12 @@ export default function TimeTrackingPage() {
fetch("/api/time-entries/summary"), fetch("/api/time-entries/summary"),
fetch("/api/projects"), fetch("/api/projects"),
]) ])
setEntries(await eRes.json()) const eData = await eRes.json()
setSummary(await sRes.json()) const sData = await sRes.json()
setProjects(await pRes.json()) const pData = 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)
} finally { } finally {
+1 -1
View File
@@ -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)}
+3 -3
View File
@@ -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],
) )
+6 -5
View File
@@ -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],
) )
+5 -3
View File
@@ -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]" />
+1 -1
View File
@@ -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}`)}
+1 -1
View File
@@ -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]" /> :
+7 -5
View File
@@ -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]" />
+6 -11
View File
@@ -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 && (
+1 -1
View File
@@ -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>