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