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) ==='
|
||||
\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 ==='
|
||||
COMMIT;
|
||||
|
||||
Generated
-3
@@ -248,7 +248,6 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -263,7 +262,6 @@
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
@@ -277,7 +275,6 @@
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
|
||||
@@ -62,7 +62,8 @@ export default function ProjectsPage() {
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
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) {
|
||||
console.error("Failed to load projects", e)
|
||||
setProjects([])
|
||||
@@ -74,7 +75,7 @@ export default function ProjectsPage() {
|
||||
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
||||
|
||||
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() : "—"
|
||||
@@ -167,7 +168,7 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {})
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -52,9 +52,12 @@ export default function TimeTrackingPage() {
|
||||
fetch("/api/time-entries/summary"),
|
||||
fetch("/api/projects"),
|
||||
])
|
||||
if (eRes.ok) setEntries(await eRes.json()); else setEntries([])
|
||||
if (sRes.ok) setSummary(await sRes.json()); else setSummary(null)
|
||||
if (pRes.ok) setProjects(await pRes.json()); else setProjects([])
|
||||
const eData = eRes.ok ? await eRes.json() : []
|
||||
const sData = sRes.ok ? await sRes.json() : null
|
||||
const pData = pRes.ok ? await pRes.json() : []
|
||||
setEntries(Array.isArray(eData) ? eData : [])
|
||||
setSummary(sData?.totals ? sData : null)
|
||||
setProjects(Array.isArray(pData) ? pData : [])
|
||||
} catch (e) {
|
||||
console.error("Failed to load time data", e)
|
||||
setEntries([])
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ClientActivity() {
|
||||
<div className="relative">
|
||||
<div className="absolute left-[17px] top-2 bottom-2 w-px bg-[#1a1a24]" />
|
||||
<div className="space-y-0">
|
||||
{events.map((e, i) => (
|
||||
{(events || []).map((e, i) => (
|
||||
<button
|
||||
key={`${e.event_type}-${e.id}-${i}`}
|
||||
onClick={() => handleClick(e)}
|
||||
|
||||
@@ -42,9 +42,9 @@ export async function GET() {
|
||||
)
|
||||
|
||||
const events = [
|
||||
...milestones.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 })),
|
||||
...(milestones.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 })),
|
||||
].sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()).slice(0, 30)
|
||||
|
||||
return NextResponse.json({ events })
|
||||
|
||||
@@ -12,10 +12,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const project = await query(
|
||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
||||
created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = $1 AND customer_id = $2 AND deleted_at IS NULL`,
|
||||
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date as end_date,
|
||||
p.budget, p.created_at, p.updated_at, s.name as status_name, s.color as status_color
|
||||
FROM projects p
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
||||
created_at, updated_at
|
||||
FROM projects
|
||||
WHERE customer_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date as end_date,
|
||||
p.budget, p.created_at, p.updated_at, s.name as status_name, s.color as status_color
|
||||
FROM projects p
|
||||
LEFT JOIN project_statuses s ON s.id = p.status_id
|
||||
WHERE p.customer_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function InvoiceDetail() {
|
||||
useEffect(() => {
|
||||
fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.then((d) => {
|
||||
if (d.invoice) setData({ ...d, items: d.items || [], receipts: d.receipts || [] })
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [id])
|
||||
|
||||
@@ -71,7 +73,7 @@ export default function InvoiceDetail() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
{(items || []).map((item, i) => (
|
||||
<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 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>
|
||||
<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 className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-[#1BB0CE]" />
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function ClientInvoices() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((inv) => (
|
||||
{(invoices || []).map((inv) => (
|
||||
<button
|
||||
key={inv.id}
|
||||
onClick={() => router.push(`/client-portal/invoices/${inv.id}`)}
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function ClientProfile() {
|
||||
|
||||
{/* Contacts sidebar */}
|
||||
<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 className="flex items-center gap-3">
|
||||
{c.type === "email" ? <Mail className="h-4 w-4 text-[#1BB0CE]" /> :
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function ProjectDetail() {
|
||||
useEffect(() => {
|
||||
fetch(`/client-portal/api/projects/${id}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.then((d) => {
|
||||
if (d.project) setData({ ...d, milestones: d.milestones || [], deliverables: d.deliverables || [] })
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [id])
|
||||
|
||||
@@ -60,10 +62,10 @@ export default function ProjectDetail() {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
milestones: prev.milestones.map((m) =>
|
||||
milestones: (prev.milestones || []).map((m) =>
|
||||
m.id === milestoneId ? { ...m, ...updated.milestone } : m,
|
||||
),
|
||||
deliverables: prev.deliverables.map((d) =>
|
||||
deliverables: (prev.deliverables || []).map((d) =>
|
||||
d.milestone_id === milestoneId
|
||||
? { ...d, status: action === "approve" ? "approved" : "rejected" }
|
||||
: d,
|
||||
@@ -120,7 +122,7 @@ export default function ProjectDetail() {
|
||||
{milestones.length === 0 ? (
|
||||
<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 Icon = si.icon
|
||||
const milestoneDeliverables = deliverables.filter((d) => d.milestone_id === m.id)
|
||||
@@ -163,7 +165,7 @@ export default function ProjectDetail() {
|
||||
{milestoneDeliverables.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
<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 className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-[#6a6a75]" />
|
||||
|
||||
@@ -8,16 +8,11 @@ interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
status_name: string
|
||||
status_color: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
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",
|
||||
budget: number
|
||||
}
|
||||
|
||||
export default function ClientProjects() {
|
||||
@@ -43,7 +38,7 @@ export default function ClientProjects() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map((p) => (
|
||||
{(projects || []).map((p) => (
|
||||
<button
|
||||
key={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 items-center gap-3 mb-1">
|
||||
<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"}`}>
|
||||
{p.status.replace("_", " ")}
|
||||
<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_name || "unknown").replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function ClientSupport() {
|
||||
</div>
|
||||
) : (
|
||||
<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 className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-white text-sm font-medium">{t.title}</h3>
|
||||
|
||||
Reference in New Issue
Block a user