Initial MRP foundation scaffold
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user