Initial MRP foundation scaffold
This commit is contained in:
70
client/src/auth/AuthProvider.tsx
Normal file
70
client/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { AuthUser } from "@mrp/shared";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface AuthContextValue {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
isReady: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
const tokenKey = "mrp.auth.token";
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => window.localStorage.getItem(tokenKey));
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
api.me(token)
|
||||
.then((nextUser) => {
|
||||
setUser(nextUser);
|
||||
})
|
||||
.catch(() => {
|
||||
window.localStorage.removeItem(tokenKey);
|
||||
setToken(null);
|
||||
})
|
||||
.finally(() => setIsReady(true));
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
token,
|
||||
user,
|
||||
isReady,
|
||||
async login(email, password) {
|
||||
const result = await api.login({ email, password });
|
||||
setToken(result.token);
|
||||
setUser(result.user);
|
||||
window.localStorage.setItem(tokenKey, result.token);
|
||||
},
|
||||
logout() {
|
||||
window.localStorage.removeItem(tokenKey);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[isReady, token, user]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
81
client/src/components/AppShell.tsx
Normal file
81
client/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Overview" },
|
||||
{ to: "/settings/company", label: "Company Settings" },
|
||||
{ to: "/crm/customers", label: "Customers" },
|
||||
{ to: "/crm/vendors", label: "Vendors" },
|
||||
{ to: "/planning/gantt", label: "Gantt" },
|
||||
];
|
||||
|
||||
export function AppShell() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-6 md:px-8">
|
||||
<div className="mx-auto flex max-w-7xl gap-6">
|
||||
<aside className="hidden w-72 shrink-0 flex-col rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel backdrop-blur md:flex">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">MRP Codex</div>
|
||||
<h1 className="mt-3 text-2xl font-extrabold text-text">Manufacturing foundation</h1>
|
||||
<p className="mt-2 text-sm text-muted">Single-tenant platform shell with branding, auth, file storage, and planning foundations.</p>
|
||||
</div>
|
||||
<nav className="mt-8 space-y-2">
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-2xl px-4 py-3 text-sm font-semibold transition ${
|
||||
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
|
||||
<p className="text-xs text-muted">{user?.email}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="mb-6 flex items-center justify-between rounded-[28px] border border-line/70 bg-surface/90 px-6 py-5 shadow-panel backdrop-blur">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Operations Command</p>
|
||||
<h2 className="text-2xl font-bold text-text">Foundation Console</h2>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<nav className="mb-6 flex gap-3 overflow-x-auto rounded-[24px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
`whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition ${
|
||||
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
client/src/components/ProtectedRoute.tsx
Normal file
22
client/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PermissionKey } from "@mrp/shared";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
|
||||
export function ProtectedRoute({ requiredPermissions = [] }: { requiredPermissions?: PermissionKey[] }) {
|
||||
const { isReady, token, user } = useAuth();
|
||||
|
||||
if (!isReady) {
|
||||
return <div className="p-10 text-center text-muted">Loading workspace...</div>;
|
||||
}
|
||||
|
||||
if (!token || !user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const permissionSet = new Set(user.permissions);
|
||||
const allowed = requiredPermissions.every((permission) => permissionSet.has(permission));
|
||||
|
||||
return allowed ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
16
client/src/components/ThemeToggle.tsx
Normal file
16
client/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useTheme } from "../theme/ThemeProvider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { mode, toggleMode } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="rounded-full border border-line/70 bg-surface px-4 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
|
||||
>
|
||||
{mode === "light" ? "Dark mode" : "Light mode"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
47
client/src/index.css
Normal file
47
client/src/index.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-family: "Manrope";
|
||||
--color-brand: 24 90 219;
|
||||
--color-accent: 0 166 166;
|
||||
--color-surface: 244 247 251;
|
||||
--color-page: 248 250 252;
|
||||
--color-text: 15 23 42;
|
||||
--color-muted: 90 106 133;
|
||||
--color-line: 215 222 235;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-brand: 63 140 255;
|
||||
--color-accent: 34 211 238;
|
||||
--color-surface: 30 41 59;
|
||||
--color-page: 2 6 23;
|
||||
--color-text: 226 232 240;
|
||||
--color-muted: 148 163 184;
|
||||
--color-line: 51 65 85;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(var(--color-brand) / 0.18), transparent 32%),
|
||||
radial-gradient(circle at top right, rgb(var(--color-accent) / 0.16), transparent 25%),
|
||||
rgb(var(--color-page));
|
||||
color: rgb(var(--color-text));
|
||||
font-family: var(--font-family), sans-serif;
|
||||
}
|
||||
|
||||
.gantt-theme .wx-bar,
|
||||
.gantt-theme .wx-task {
|
||||
fill: rgb(var(--color-brand));
|
||||
}
|
||||
|
||||
100
client/src/lib/api.ts
Normal file
100
client/src/lib/api.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
CompanyProfileDto,
|
||||
CompanyProfileInput,
|
||||
FileAttachmentDto,
|
||||
GanttLinkDto,
|
||||
GanttTaskDto,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
} from "@mrp/shared";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(input: string, init?: RequestInit, token?: string): Promise<T> {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const json = (await response.json()) as ApiResponse<T>;
|
||||
if (!json.ok) {
|
||||
throw new ApiError(json.error.message, json.error.code);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login(payload: LoginRequest) {
|
||||
return request<LoginResponse>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
me(token: string) {
|
||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||
},
|
||||
getCompanyProfile(token: string) {
|
||||
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
|
||||
},
|
||||
updateCompanyProfile(token: string, payload: CompanyProfileInput) {
|
||||
return request<CompanyProfileDto>(
|
||||
"/api/v1/company-profile",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
async uploadFile(token: string, file: File, ownerType: string, ownerId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("ownerType", ownerType);
|
||||
formData.append("ownerId", ownerId);
|
||||
|
||||
const response = await fetch("/api/v1/files/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
const json = (await response.json()) as ApiResponse<FileAttachmentDto>;
|
||||
if (!json.ok) {
|
||||
throw new ApiError(json.error.message, json.error.code);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
getCustomers(token: string) {
|
||||
return request<Array<Record<string, string>>>("/api/v1/crm/customers", undefined, token);
|
||||
},
|
||||
getVendors(token: string) {
|
||||
return request<Array<Record<string, string>>>("/api/v1/crm/vendors", undefined, token);
|
||||
},
|
||||
getGanttDemo(token: string) {
|
||||
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
|
||||
},
|
||||
async getCompanyProfilePreviewPdf(token: string) {
|
||||
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render company profile preview PDF.", "PDF_PREVIEW_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
};
|
||||
63
client/src/main.tsx
Normal file
63
client/src/main.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
|
||||
import { permissions } from "@mrp/shared";
|
||||
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { DashboardPage } from "./modules/dashboard/DashboardPage";
|
||||
import { LoginPage } from "./modules/login/LoginPage";
|
||||
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
|
||||
import { CustomersPage } from "./modules/crm/CustomersPage";
|
||||
import { VendorsPage } from "./modules/crm/VendorsPage";
|
||||
import { GanttPage } from "./modules/gantt/GanttPage";
|
||||
import { ThemeProvider } from "./theme/ThemeProvider";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ path: "/", element: <DashboardPage /> },
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
|
||||
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
|
||||
children: [
|
||||
{ path: "/crm/customers", element: <CustomersPage /> },
|
||||
{ path: "/crm/vendors", element: <VendorsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
|
||||
children: [{ path: "/planning/gantt", element: <GanttPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
46
client/src/modules/crm/CustomersPage.tsx
Normal file
46
client/src/modules/crm/CustomersPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export function CustomersPage() {
|
||||
const { token } = useAuth();
|
||||
const [customers, setCustomers] = useState<Array<Record<string, string>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
api.getCustomers(token).then(setCustomers);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">Customers</h3>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Name</th>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Phone</th>
|
||||
<th className="px-4 py-3">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.id}>
|
||||
<td className="px-4 py-3 font-semibold text-text">{customer.name}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.email}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.phone}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.city}, {customer.state}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
34
client/src/modules/crm/VendorsPage.tsx
Normal file
34
client/src/modules/crm/VendorsPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export function VendorsPage() {
|
||||
const { token } = useAuth();
|
||||
const [vendors, setVendors] = useState<Array<Record<string, string>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
api.getVendors(token).then(setVendors);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">Vendors</h3>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{vendors.map((vendor) => (
|
||||
<article key={vendor.id} className="rounded-2xl border border-line/70 bg-page/70 p-5">
|
||||
<h4 className="text-lg font-bold text-text">{vendor.name}</h4>
|
||||
<p className="mt-2 text-sm text-muted">{vendor.email}</p>
|
||||
<p className="text-sm text-muted">{vendor.phone}</p>
|
||||
<p className="mt-3 text-sm text-muted">{vendor.city}, {vendor.state}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
38
client/src/modules/dashboard/DashboardPage.tsx
Normal file
38
client/src/modules/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Foundation Status</p>
|
||||
<h3 className="mt-3 text-3xl font-bold text-text">Platform primitives are online.</h3>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-muted">
|
||||
Authentication, RBAC, runtime branding, attachment storage, Docker deployment, and a planning visualization wrapper are now structured for future domain expansion.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white" to="/settings/company">
|
||||
Manage company profile
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-5 py-3 text-sm font-semibold text-text" to="/planning/gantt">
|
||||
Open gantt preview
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roadmap</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
{[
|
||||
"CRM reference entities are seeded and available via protected APIs.",
|
||||
"Company Settings drives runtime brand tokens and PDF identity.",
|
||||
"The next module phase can add BOMs, orders, and shipping documents without app-shell refactors.",
|
||||
].map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-text">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
client/src/modules/gantt/GanttPage.tsx
Normal file
43
client/src/modules/gantt/GanttPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Gantt } from "@svar-ui/react-gantt";
|
||||
import "@svar-ui/react-gantt/style.css";
|
||||
|
||||
import type { GanttLinkDto, GanttTaskDto } from "@mrp/shared";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export function GanttPage() {
|
||||
const { token } = useAuth();
|
||||
const [tasks, setTasks] = useState<GanttTaskDto[]>([]);
|
||||
const [links, setLinks] = useState<GanttLinkDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getGanttDemo(token).then((data) => {
|
||||
setTasks(data.tasks);
|
||||
setLinks(data.links);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">SVAR Gantt Preview</h3>
|
||||
<p className="mt-2 text-sm text-muted">Theme-aware integration wrapper prepared for future manufacturing schedules and task dependencies.</p>
|
||||
<div className="gantt-theme mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||
<Gantt
|
||||
tasks={tasks.map((task) => ({
|
||||
...task,
|
||||
start: new Date(task.start),
|
||||
end: new Date(task.end),
|
||||
}))}
|
||||
links={links}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
76
client/src/modules/login/LoginPage.tsx
Normal file
76
client/src/modules/login/LoginPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, token } = useAuth();
|
||||
const [email, setEmail] = useState("admin@mrp.local");
|
||||
const [password, setPassword] = useState("ChangeMe123!");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (token) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (submissionError) {
|
||||
setError(submissionError instanceof Error ? submissionError.message : "Unable to sign in.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-[32px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="bg-brand px-8 py-12 text-white md:px-12">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-white/75">MRP Codex</p>
|
||||
<h1 className="mt-6 text-4xl font-extrabold">A streamlined manufacturing operating system.</h1>
|
||||
<p className="mt-5 max-w-xl text-base text-white/82">
|
||||
This foundation release establishes authentication, company settings, brand theming, file persistence, and planning scaffolding.
|
||||
</p>
|
||||
</section>
|
||||
<section className="px-8 py-12 md:px-12">
|
||||
<h2 className="text-2xl font-bold text-text">Sign in</h2>
|
||||
<p className="mt-2 text-sm text-muted">Use the seeded admin account to access the initial platform shell.</p>
|
||||
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="rounded-2xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-2xl bg-text px-4 py-3 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Enter workspace"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
177
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
177
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { CompanyProfileInput } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { useTheme } from "../../theme/ThemeProvider";
|
||||
|
||||
export function CompanySettingsPage() {
|
||||
const { token } = useAuth();
|
||||
const { applyBrandProfile } = useTheme();
|
||||
const [form, setForm] = useState<CompanyProfileInput | null>(null);
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string>("Loading company profile...");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getCompanyProfile(token).then((profile) => {
|
||||
setCompanyId(profile.id);
|
||||
setLogoUrl(profile.logoUrl);
|
||||
setForm({
|
||||
companyName: profile.companyName,
|
||||
legalName: profile.legalName,
|
||||
email: profile.email,
|
||||
phone: profile.phone,
|
||||
website: profile.website,
|
||||
taxId: profile.taxId,
|
||||
addressLine1: profile.addressLine1,
|
||||
addressLine2: profile.addressLine2,
|
||||
city: profile.city,
|
||||
state: profile.state,
|
||||
postalCode: profile.postalCode,
|
||||
country: profile.country,
|
||||
theme: profile.theme,
|
||||
});
|
||||
applyBrandProfile(profile);
|
||||
setStatus("Company profile loaded.");
|
||||
});
|
||||
}, [applyBrandProfile, token]);
|
||||
|
||||
if (!form || !token) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !form) {
|
||||
return;
|
||||
}
|
||||
const profile = await api.updateCompanyProfile(token, form);
|
||||
applyBrandProfile(profile);
|
||||
setLogoUrl(profile.logoUrl);
|
||||
setStatus("Company settings saved.");
|
||||
}
|
||||
|
||||
async function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !companyId || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = await api.uploadFile(token, file, "company-profile", companyId);
|
||||
setForm((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
theme: {
|
||||
...current.theme,
|
||||
logoFileId: attachment.id,
|
||||
},
|
||||
}
|
||||
: current
|
||||
);
|
||||
setLogoUrl(`/api/v1/files/${attachment.id}/content`);
|
||||
setStatus("Logo uploaded. Save to persist it on the profile.");
|
||||
}
|
||||
|
||||
async function handlePdfPreview() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.getCompanyProfilePreviewPdf(token);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
|
||||
function updateField<Key extends keyof CompanyProfileInput>(key: Key, value: CompanyProfileInput[Key]) {
|
||||
setForm((current) => (current ? { ...current, [key]: value } : current));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSave}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">Branding and legal identity</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-dashed border-line/70 bg-page/80 p-4">
|
||||
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
||||
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
||||
Upload logo
|
||||
<input className="hidden" type="file" accept="image/*" onChange={handleLogoUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2">
|
||||
{[
|
||||
["companyName", "Company name"],
|
||||
["legalName", "Legal name"],
|
||||
["email", "Email"],
|
||||
["phone", "Phone"],
|
||||
["website", "Website"],
|
||||
["taxId", "Tax ID"],
|
||||
["addressLine1", "Address line 1"],
|
||||
["addressLine2", "Address line 2"],
|
||||
["city", "City"],
|
||||
["state", "State"],
|
||||
["postalCode", "Postal code"],
|
||||
["country", "Country"],
|
||||
].map(([key, label]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
|
||||
<input
|
||||
value={String(form[key as keyof CompanyProfileInput])}
|
||||
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
|
||||
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
|
||||
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
|
||||
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
|
||||
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
|
||||
<span className="text-sm text-muted">{status}</span>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePdfPreview}
|
||||
className="rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
|
||||
>
|
||||
Preview PDF
|
||||
</button>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
2
client/src/tests/setup.ts
Normal file
2
client/src/tests/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
20
client/src/tests/theme.test.tsx
Normal file
20
client/src/tests/theme.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThemeProvider } from "../theme/ThemeProvider";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
it("toggles the html dark class", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeToggle />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
58
client/src/theme/ThemeProvider.tsx
Normal file
58
client/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { CompanyProfileDto } from "@mrp/shared";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { hexToRgbTriplet } from "./utils";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: ThemeMode;
|
||||
toggleMode: () => void;
|
||||
applyBrandProfile: (profile: CompanyProfileDto | null) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
const storageKey = "mrp.theme.mode";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||
window.localStorage.setItem(storageKey, mode);
|
||||
}, [mode]);
|
||||
|
||||
const applyBrandProfile = (profile: CompanyProfileDto | null) => {
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
|
||||
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
|
||||
document.documentElement.style.setProperty("--color-surface", hexToRgbTriplet(profile.theme.surfaceColor));
|
||||
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
mode,
|
||||
toggleMode: () => setMode((current) => (current === "light" ? "dark" : "light")),
|
||||
applyBrandProfile,
|
||||
}),
|
||||
[mode]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
9
client/src/theme/utils.ts
Normal file
9
client/src/theme/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function hexToRgbTriplet(hex: string) {
|
||||
const normalized = hex.replace("#", "");
|
||||
const numeric = Number.parseInt(normalized, 16);
|
||||
const r = (numeric >> 16) & 255;
|
||||
const g = (numeric >> 8) & 255;
|
||||
const b = numeric & 255;
|
||||
return `${r} ${g} ${b}`;
|
||||
}
|
||||
|
||||
2
client/src/vite-env.d.ts
vendored
Normal file
2
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
Reference in New Issue
Block a user