Initial MRP foundation scaffold

This commit is contained in:
2026-03-14 14:44:40 -05:00
commit ee833ed074
77 changed files with 10218 additions and 0 deletions

View 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;
}

View 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>
);
}

View 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 />;
}

View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";

View 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);
});
});

View 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;
}

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />