From 9849335d10a1920af7a7b6a7c1680be71bb4c998 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 19 May 2026 18:55:21 +0800 Subject: [PATCH] Add ERP project tracking views --- .env.example | 4 + apps/admin_dashboard/src/main.tsx | 1378 ++++++++++++++- apps/api/src/five08/backend/api.py | 1500 +++++++++++++++-- apps/api/src/five08/backend/auth.py | 7 + apps/api/src/five08/backend/dashboard.py | 1 + .../static/dashboard/.vite/manifest.json | 4 +- .../static/dashboard/assets/index-617IyoY5.js | 9 + .../static/dashboard/assets/index-CHUx-bdo.js | 9 - .../dashboard/assets/index-Cdw3ivTK.css | 2 - .../dashboard/assets/index-Cjc8hvil.css | 2 + .../backend/static/dashboard/index.html | 4 +- .../five08/discord_bot/cogs/admin_login.py | 8 + .../src/five08/discord_bot/cogs/projects.py | 144 ++ .../src/five08/worker/erpnext_project_sync.py | 98 ++ apps/worker/src/five08/worker/jobs.py | 9 + ...260519_0100_create_project_cache_tables.py | 297 ++++ .../shared/src/five08/clients/__init__.py | 12 +- packages/shared/src/five08/clients/erpnext.py | 301 ++++ packages/shared/src/five08/projects.py | 1135 +++++++++++++ packages/shared/src/five08/settings.py | 3 + tests/unit/test_admin_login_cog.py | 30 + tests/unit/test_backend_api.py | 827 +++++++++ tests/unit/test_erpnext_client.py | 63 + tests/unit/test_projects.py | 401 +++++ .../unit/test_worker_erpnext_project_sync.py | 103 ++ 25 files changed, 6199 insertions(+), 152 deletions(-) create mode 100644 apps/api/src/five08/backend/static/dashboard/assets/index-617IyoY5.js delete mode 100644 apps/api/src/five08/backend/static/dashboard/assets/index-CHUx-bdo.js delete mode 100644 apps/api/src/five08/backend/static/dashboard/assets/index-Cdw3ivTK.css create mode 100644 apps/api/src/five08/backend/static/dashboard/assets/index-Cjc8hvil.css create mode 100644 apps/discord_bot/src/five08/discord_bot/cogs/projects.py create mode 100644 apps/worker/src/five08/worker/erpnext_project_sync.py create mode 100644 apps/worker/src/five08/worker/migrations/versions/20260519_0100_create_project_cache_tables.py create mode 100644 packages/shared/src/five08/clients/erpnext.py create mode 100644 packages/shared/src/five08/projects.py create mode 100644 tests/unit/test_erpnext_client.py create mode 100644 tests/unit/test_projects.py create mode 100644 tests/unit/test_worker_erpnext_project_sync.py diff --git a/.env.example b/.env.example index ea74b543..1f9dd40c 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,10 @@ AUTHENTIK_RECOVERY_EMAIL_STAGE_NAME=default-recovery-email OUTLINE_BASE_URL=https://app.getoutline.com OUTLINE_API_TIMEOUT_SECONDS=20.0 OUTLINE_API_KEY= +# ERPNext project tracking (required for project dashboard sync and /projects) +ERPNEXT_BASE_URL=https://erp.example.com/ +ERPNEXT_API_TIMEOUT_SECONDS=20.0 +ERPNEXT_API_KEY= # Optional: Discord logs webhook for operator-visible logs from commands/jobs DISCORD_LOGS_WEBHOOK_URL= # Optional: wait for Discord server confirmation before returning from webhook call diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index 8cd11bf0..0b998bc8 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -5,6 +5,7 @@ import { ClipboardList, ExternalLink, FileClock, + FolderKanban, LogOut, RefreshCw, Search, @@ -44,7 +45,7 @@ import { import { cn } from "@/lib/utils" import "./index.css" -type View = "people" | "gigs" | "onboarding" | "jobs" | "agent" | "audit" +type View = "people" | "gigs" | "projects" | "onboarding" | "jobs" | "agent" | "audit" type SortDirection = "asc" | "desc" type User = { @@ -164,6 +165,91 @@ type DashboardNotificationsResponse = { notifications: DashboardNotification[] } +type ProjectRosterMember = { + source?: string + source_user_id?: string + email?: string + full_name?: string + roster_kind?: string + crm_contact_id?: string + erpnext_user_url?: string + supplier_erpnext_url?: string + last_seen_at?: string +} + +type HistoricalPersonCandidate = { + candidate_id: string + label?: string + full_name?: string + email?: string + crm_contact_id?: string + erpnext_user_id?: string + supplier_erpnext_id?: string + supplier_name?: string + sources?: string[] +} + +type Project = { + id: string + erpnext_project_id?: string + erpnext_project_url?: string + display_name: string + customer?: string + customer_erpnext_url?: string + source_status?: string + project_type?: string + priority?: string + percent_complete?: number | null + expected_start_date?: string + expected_end_date?: string + actual_start_date?: string + actual_end_date?: string + source_modified_at?: string + last_synced_at?: string + linked_engagement_count?: number + roster_count?: number + roster_members?: ProjectRosterMember[] +} + +type ProjectsResponse = { + projects: Project[] + summary: { + project_count?: number + open_project_count?: number + projects_with_roster?: number + roster_member_count?: number + last_synced_at?: string + } +} + +type WikiMatchPreview = { + document?: { + title?: string + updatedAt?: string + urlId?: string + } + wiki_rows?: Array> + matches?: Array<{ + project?: Project + best_match?: { + score?: number + confidence?: string + row?: Record | null + } | null + fuzzy_match?: { + score?: number + confidence?: string + row?: Record | null + } | null + manual_match?: { + match_status?: string + wiki_row_key?: string + wiki_row_label?: string + wiki_row_section?: string + } | null + }> +} + type AuditEvent = { id?: string occurred_at?: string @@ -190,6 +276,7 @@ type AgentReport = { const routes: Record = { people: "/dashboard/people", gigs: "/dashboard/gigs", + projects: "/dashboard/projects", onboarding: "/dashboard/onboarding", jobs: "/dashboard/jobs", agent: "/dashboard/agent", @@ -199,6 +286,7 @@ const routes: Record = { const routePermissions: Record = { people: "people:read", gigs: "gigs:read", + projects: "projects:read", onboarding: "onboarding:read", jobs: "jobs:read", agent: "audit:read", @@ -247,6 +335,18 @@ const peopleFilterDefinitions = { type PeopleFilterKey = keyof typeof peopleFilterDefinitions type FilterState = Partial> +class ApiRequestError extends Error { + status: number + payload: unknown + + constructor(message: string, status: number, payload: unknown) { + super(message) + this.name = "ApiRequestError" + this.status = status + this.payload = payload + } +} + function rawViewFromPath() { return window.location.pathname.split("/").filter(Boolean)[1] || "" } @@ -256,9 +356,9 @@ function viewFromPath(): View { return Object.hasOwn(routes, view) ? (view as View) : "people" } -function detailIdFromPath() { +function detailIdFromPath(expectedView: "gigs" | "projects" = "gigs") { const [, view, detailId] = window.location.pathname.split("/").filter(Boolean) - if (view !== "gigs" || !detailId) return "" + if (view !== expectedView || !detailId) return "" try { return decodeURIComponent(detailId) } catch { @@ -281,18 +381,26 @@ async function requestJson(url: string, options: RequestInit = {}): Promise + detail = record.detail || record.error || detail + } } catch { detail = response.statusText } - throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)) + throw new ApiRequestError( + typeof detail === "string" ? detail : JSON.stringify(detail), + response.status, + payload, + ) } return response.json() as Promise } -function sortValue(scope: View, item: Job | Person | Gig | AuditEvent, key: string) { +function sortValue(scope: View, item: Job | Person | Gig | Project | AuditEvent, key: string) { if (scope === "gigs") { const gig = item as Gig if (key === "title") return gig.title || "" @@ -300,6 +408,14 @@ function sortValue(scope: View, item: Job | Person | Gig | AuditEvent, key: stri if (key === "applications") return Number(gig.application_count || 0) if (key === "activity") return gigActivityTimestamp(gig) } + if (scope === "projects") { + const project = item as Project + if (key === "display_name") return project.display_name || "" + if (key === "customer") return project.customer || "" + if (key === "status") return project.source_status || "" + if (key === "roster_count") return Number(project.roster_count || 0) + if (key === "modified") return project.source_modified_at || project.last_synced_at || "" + } if (scope === "onboarding") { const person = item as Person const status = person.profile_status || {} @@ -342,7 +458,7 @@ function sortValue(scope: View, item: Job | Person | Gig | AuditEvent, key: stri return (item as Record)[key] ?? "" } -function sortItems( +function sortItems( scope: View, items: T[], sort: { key: string; direction: SortDirection }, @@ -427,6 +543,7 @@ function Empty({ children, hidden }: { children: string; hidden: boolean }) { } function App() { + const initialProjectDetailId = detailIdFromPath("projects") const [user, setUser] = useState(null) const [view, setViewState] = useState(viewFromPath()) const [toast, setToast] = useState<{ message: string; tone?: "ok" | "error" }>({ @@ -436,8 +553,12 @@ function App() { const [crmBaseUrl, setCrmBaseUrl] = useState("") const [jobs, setJobs] = useState([]) const [gigs, setGigs] = useState([]) + const [projects, setProjects] = useState([]) + const [projectsSummary, setProjectsSummary] = useState({}) + const [wikiMatches, setWikiMatches] = useState(null) const [gigDetail, setGigDetail] = useState(null) const [selectedGigId, setSelectedGigId] = useState(detailIdFromPath()) + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectDetailId) const [notifications, setNotifications] = useState([]) const [notificationsOpen, setNotificationsOpen] = useState(false) const [people, setPeople] = useState([]) @@ -446,9 +567,15 @@ function App() { const [agentReport, setAgentReport] = useState(null) const [jobDetail, setJobDetail] = useState(null) const [loading, setLoading] = useState>({}) + const [historicalPersonChoice, setHistoricalPersonChoice] = useState<{ + projectId: string + person: string + candidates: HistoricalPersonCandidate[] + } | null>(null) const [sort, setSortState] = useState>({ onboarding: { key: "onboarding_state", direction: "asc" }, gigs: { key: "activity", direction: "desc" }, + projects: { key: "display_name", direction: "asc" }, jobs: { key: "updated_at", direction: "desc" }, people: { key: "name", direction: "asc" }, agent: { key: "occurred_at", direction: "desc" }, @@ -460,6 +587,8 @@ function App() { const [jobType, setJobType] = useState("") const [gigStatus, setGigStatus] = useState("") const [gigLimit, setGigLimit] = useState(100) + const [projectQuery, setProjectQuery] = useState("") + const [projectStatus, setProjectStatus] = useState(initialProjectDetailId ? "" : "Open") const [staleRecruitingDays, setStaleRecruitingDays] = useState(7) const [peopleQuery, setPeopleQuery] = useState("") const [peopleMember, setPeopleMember] = useState("") @@ -504,7 +633,9 @@ function App() { normalized = firstAllowedView() } if (normalized !== "gigs") setSelectedGigId("") + if (normalized !== "projects") setSelectedProjectId("") if (normalized === "gigs" && push) setSelectedGigId("") + if (normalized === "projects" && push) setSelectedProjectId("") setViewState(normalized) if (push) { window.history.pushState({ view: normalized }, "", routes[normalized]) @@ -554,6 +685,21 @@ function App() { window.history.replaceState({ view: "gigs" }, "", routes.gigs) } + function openProjectDetail(projectId: string) { + setSelectedProjectId(projectId) + setViewState("projects") + window.history.pushState( + { view: "projects", projectId }, + "", + `/dashboard/projects/${encodeURIComponent(projectId)}`, + ) + } + + function closeProjectDetail() { + setSelectedProjectId("") + window.history.replaceState({ view: "projects" }, "", routes.projects) + } + async function loadUser() { const payload = await requestJson("/dashboard/api/me") setUser(payload) @@ -576,6 +722,12 @@ function App() { return `/dashboard/api/gigs?${params.toString()}` } + function projectsUrl() { + const params = new URLSearchParams({ limit: "100", status: projectStatus }) + if (projectQuery.trim()) params.set("query", projectQuery.trim()) + return `/dashboard/api/projects?${params.toString()}` + } + async function loadJobs() { setBusy("jobs", true) showToast("Loading jobs") @@ -604,6 +756,194 @@ function App() { } } + async function loadProjects() { + setBusy("projects", true) + try { + const payload = await requestJson(projectsUrl()) + setProjects(payload.projects || []) + setProjectsSummary(payload.summary || {}) + showToast( + `Loaded ${(payload.projects || []).length} project${(payload.projects || []).length === 1 ? "" : "s"}`, + "ok", + ) + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to load projects", "error") + } finally { + setBusy("projects", false) + } + } + + async function syncProjects() { + setBusy("syncProjects", true) + showToast("Queueing project sync") + try { + const payload = await requestJson<{ job_id: string }>("/dashboard/api/sync/projects", { + method: "POST", + }) + showToast(`Queued project sync ${payload.job_id}`, "ok") + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to queue project sync", "error") + } finally { + setBusy("syncProjects", false) + } + } + + async function updateProjectStatus(projectId: string, nextStatus: string) { + setBusy(`project:${projectId}:status`, true) + try { + const payload = await requestJson<{ project: Project }>( + `/dashboard/api/projects/${encodeURIComponent(projectId)}/status`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: nextStatus }), + }, + ) + setProjects((current) => + current.map((project) => (project.id === projectId ? payload.project : project)), + ) + showToast("Updated project status", "ok") + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to update project", "error") + } finally { + setBusy(`project:${projectId}:status`, false) + } + } + + async function bulkUpdateProjects( + projectIds: string[], + updates: { status?: string; project_type?: string }, + ) { + if (projectIds.length === 0) return false + setBusy("projectsBulkUpdate", true) + try { + const payload = await requestJson<{ + projects: Project[] + failures: Array<{ error?: string }> + }>("/dashboard/api/projects/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_ids: projectIds, ...updates }), + }) + const updatedProjects = payload.projects || [] + setProjects((current) => + current.map((project) => updatedProjects.find((item) => item.id === project.id) || project), + ) + const failures = payload.failures || [] + showToast( + failures.length + ? `Updated ${updatedProjects.length}; ${failures.length} failed` + : `Updated ${updatedProjects.length} project${updatedProjects.length === 1 ? "" : "s"}`, + failures.length ? "error" : "ok", + ) + return failures.length === 0 + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to bulk update projects", "error") + return false + } finally { + setBusy("projectsBulkUpdate", false) + } + } + + async function addProjectUser(projectId: string, userName: string) { + const normalizedUser = userName.trim() + if (!normalizedUser) return false + setBusy(`project:${projectId}:user`, true) + try { + const payload = await requestJson<{ project: Project }>( + `/dashboard/api/projects/${encodeURIComponent(projectId)}/users`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: normalizedUser }), + }, + ) + setProjects((current) => + current.map((project) => (project.id === projectId ? payload.project : project)), + ) + showToast("Added project user", "ok") + return true + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to add project user", "error") + return false + } finally { + setBusy(`project:${projectId}:user`, false) + } + } + + async function addHistoricalProjectMember( + projectId: string, + person: string, + candidateId?: string, + ) { + const normalizedPerson = person.trim() + if (!normalizedPerson) return false + setBusy(`project:${projectId}:historical`, true) + try { + const payload = await requestJson<{ project: Project }>( + `/dashboard/api/projects/${encodeURIComponent(projectId)}/historical-members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ person: normalizedPerson, candidate_id: candidateId }), + }, + ) + setProjects((current) => + current.map((project) => (project.id === projectId ? payload.project : project)), + ) + setHistoricalPersonChoice(null) + showToast("Added historical project member", "ok") + return true + } catch (error) { + if (error instanceof ApiRequestError && error.status === 409) { + const payload = error.payload as { candidates?: HistoricalPersonCandidate[] } | null + const candidates = payload?.candidates || [] + if (candidates.length > 0) { + setHistoricalPersonChoice({ projectId, person: normalizedPerson, candidates }) + showToast("Choose the matching person record", "error") + return false + } + } + showToast(error instanceof Error ? error.message : "Unable to add historical member", "error") + return false + } finally { + setBusy(`project:${projectId}:historical`, false) + } + } + + async function updateProjectWikiMatch(projectId: string, status: string, rowKey?: string) { + setBusy(`project:${projectId}:wiki`, true) + try { + await requestJson<{ manual_match: object }>( + `/dashboard/api/projects/${encodeURIComponent(projectId)}/wiki-match`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status, row_key: rowKey }), + }, + ) + showToast(status === "no_row" ? "Marked as no wiki row" : "Confirmed wiki match", "ok") + await loadWikiMatches() + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to save wiki match", "error") + } finally { + setBusy(`project:${projectId}:wiki`, false) + } + } + + async function loadWikiMatches() { + setBusy("wikiMatches", true) + try { + const payload = await requestJson("/dashboard/api/projects/wiki-matches") + setWikiMatches(payload) + showToast("Loaded wiki match preview", "ok") + } catch (error) { + showToast(error instanceof Error ? error.message : "Unable to load wiki matches", "error") + } finally { + setBusy("wikiMatches", false) + } + } + async function loadGigDetail(gigId: string) { setBusy(`gig:${gigId}:detail`, true) try { @@ -882,6 +1222,7 @@ function App() { nextPermissions.includes(routePermissions[candidate]), ) || "people" setSelectedGigId(nextView === "gigs" ? detailIdFromPath() : "") + setSelectedProjectId(nextView === "projects" ? detailIdFromPath("projects") : "") setViewState(nextView) if (!Object.hasOwn(routes, rawViewFromPath()) || nextView !== currentView) { window.history.replaceState({ view: nextView }, "", routes[nextView]) @@ -895,6 +1236,7 @@ function App() { useEffect(() => { const onPopState = () => { setSelectedGigId(detailIdFromPath()) + setSelectedProjectId(detailIdFromPath("projects")) navigateRef.current(viewFromPath(), false) } window.addEventListener("popstate", onPopState) @@ -913,6 +1255,7 @@ function App() { if (can("gigs:read")) void loadNotifications() if (view === "people") void loadPeople() if (view === "gigs") void loadGigs() + if (view === "projects") void loadProjects() if (view === "onboarding") void loadOnboarding() if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() @@ -925,6 +1268,7 @@ function App() { if (can("gigs:read")) void loadNotifications() if (view === "people") void loadPeople() if (view === "gigs") void loadGigs() + if (view === "projects") void loadProjects() if (view === "onboarding") void loadOnboarding() if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() @@ -941,6 +1285,11 @@ function App() { if (view === "gigs" && permissions.length > 0) void loadGigs() }, [gigStatus, gigLimit]) + // biome-ignore lint/correctness/useExhaustiveDependencies: projects reload intentionally follows status changes only while projects is active. + useEffect(() => { + if (view === "projects" && permissions.length > 0) void loadProjects() + }, [projectStatus]) + // biome-ignore lint/correctness/useExhaustiveDependencies: detail reload intentionally follows the route-selected gig id. useEffect(() => { if (view === "gigs" && selectedGigId && permissions.length > 0) { @@ -978,10 +1327,18 @@ function App() { [onboarding, sort.onboarding], ) const sortedGigs = useMemo(() => sortItems("gigs", gigs, sort.gigs), [gigs, sort.gigs]) + const sortedProjects = useMemo( + () => sortItems("projects", projects, sort.projects), + [projects, sort.projects], + ) const selectedGig = useMemo(() => { if (gigDetail?.id === selectedGigId) return gigDetail return sortedGigs.find((gig) => gig.id === selectedGigId) || null }, [gigDetail, selectedGigId, sortedGigs]) + const selectedProject = useMemo( + () => sortedProjects.find((project) => project.id === selectedProjectId) || null, + [selectedProjectId, sortedProjects], + ) const sortedAudit = useMemo( () => sortItems("audit", auditEvents, sort.audit), [auditEvents, sort.audit], @@ -1106,6 +1463,23 @@ function App() { onOpenNotification={openNotification} /> + setHistoricalPersonChoice(null)} + onChoose={(candidateId) => { + if (!historicalPersonChoice) return + void addHistoricalProjectMember( + historicalPersonChoice.projectId, + historicalPersonChoice.person, + candidateId, + ) + }} + />