mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix: the issue in the sheets file
This commit is contained in:
@@ -8,6 +8,12 @@ export interface ActionShellProps {
|
||||
subtitle?: string;
|
||||
/** When true the action is already done — children are hidden, a done badge shows. */
|
||||
done?: boolean;
|
||||
/**
|
||||
* Keep the input controls mounted alongside the done badge. For actions whose
|
||||
* value stays correctable after completion (e.g. customs risk), rather than
|
||||
* the default one-and-done actions.
|
||||
*/
|
||||
keepChildrenWhenDone?: boolean;
|
||||
doneLabel?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -22,6 +28,7 @@ export function ActionShell({
|
||||
title,
|
||||
subtitle,
|
||||
done,
|
||||
keepChildrenWhenDone,
|
||||
doneLabel,
|
||||
children,
|
||||
}: ActionShellProps) {
|
||||
@@ -64,7 +71,7 @@ export function ActionShell({
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{!done ? children : null}
|
||||
{!done || keepChildrenWhenDone ? children : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -15,22 +15,42 @@ const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
||||
export function AssignRiskCard({
|
||||
bookingId,
|
||||
milestone,
|
||||
locked = false,
|
||||
}: {
|
||||
bookingId: string;
|
||||
milestone: Freight.IClearanceMilestone;
|
||||
/**
|
||||
* Duty has already been advised off this risk level, so the decision is now
|
||||
* final. Until then a mis-assigned level must stay correctable — the server
|
||||
* accepts reassignment and overwrites the milestone metadata.
|
||||
*/
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const assign = useAssignRisk(bookingId);
|
||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
|
||||
|
||||
const assigned = milestone.status === "COMPLETED";
|
||||
const current = milestone.metadata?.riskLevel;
|
||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>(
|
||||
current ?? "GREEN",
|
||||
);
|
||||
|
||||
// The milestone loads (and refetches after a reassignment) after first render,
|
||||
// so mirror the persisted level onto the control whenever it changes.
|
||||
useEffect(() => {
|
||||
if (current) setLevel(current);
|
||||
}, [current]);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={ShieldAlert}
|
||||
title="Customs risk"
|
||||
subtitle="Assign the customs examination risk level."
|
||||
subtitle={
|
||||
assigned && !locked
|
||||
? "Reassign the customs examination risk level."
|
||||
: "Assign the customs examination risk level."
|
||||
}
|
||||
done={assigned}
|
||||
keepChildrenWhenDone={!locked}
|
||||
doneLabel={
|
||||
current ? (
|
||||
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
||||
@@ -60,9 +80,10 @@ export function AssignRiskCard({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={assign.isPending}
|
||||
disabled={assigned && level === current}
|
||||
onClick={() => assign.mutate({ riskLevel: level })}
|
||||
>
|
||||
Assign risk
|
||||
{assigned ? "Reassign risk" : "Assign risk"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||
|
||||
{riskMs ? (
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
<AssignRiskCard
|
||||
bookingId={bookingId}
|
||||
milestone={riskMs}
|
||||
locked={dutyMs?.status === "COMPLETED"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IncidentReportCard bookingId={bookingId} />
|
||||
|
||||
@@ -217,7 +217,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
|
||||
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||
useAuth();
|
||||
|
||||
// Keep the server session alive while a user is logged in. Runs after
|
||||
@@ -274,7 +274,7 @@ const App = () => {
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfileAndSwitch}
|
||||
onCreateProfile={createProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
|
||||
@@ -344,6 +344,15 @@ export default function OnboardingWizardDialog({
|
||||
[finishMutation],
|
||||
);
|
||||
|
||||
// Closing from the congratulations panel also clears the completed flag so a
|
||||
// future reopen (shouldn't happen once onboarded) starts clean.
|
||||
// Must stay above the `!user` early return: `user` flips to null while
|
||||
// useAuth refetches, and skipping a hook on that render breaks hook order.
|
||||
const handleClose = useCallback(() => {
|
||||
if (completed) setCompleted(false);
|
||||
onClose();
|
||||
}, [completed, onClose]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
||||
@@ -365,13 +374,6 @@ export default function OnboardingWizardDialog({
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
// Closing from the congratulations panel also clears the completed flag so a
|
||||
// future reopen (shouldn't happen once onboarded) starts clean.
|
||||
const handleClose = useCallback(() => {
|
||||
if (completed) setCompleted(false);
|
||||
onClose();
|
||||
}, [completed, onClose]);
|
||||
|
||||
// Prefer the backend-resolved document code; fall back to the local mapping
|
||||
// only until the requirements query lands (the documents step is reached well
|
||||
// after the draft — and thus the requirements — exist).
|
||||
|
||||
@@ -211,7 +211,11 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createProfileAndSwitch = async (
|
||||
/**
|
||||
* Add an operational role. The new role starts pending review, so the active
|
||||
* mode is left untouched — the user keeps working under their approved role.
|
||||
*/
|
||||
const createProfile = async (
|
||||
type: ProfileTypeValue,
|
||||
licenseFiles: File[],
|
||||
): Promise<Result<void>> => {
|
||||
@@ -278,7 +282,7 @@ const useAuth = () => {
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
signup,
|
||||
|
||||
@@ -83,7 +83,11 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
||||
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
||||
const roleOptions = rolesForCompanyType(profile.companyType);
|
||||
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
|
||||
// Only an approved role is a role the company actually operates as. A pending
|
||||
// one carries no reference yet, and must not read as granted.
|
||||
const activeRoles = roleOptions.filter(
|
||||
(o) => refByType.get(o.type)?.status === "active",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -248,9 +248,8 @@ export default function CompanyProfileForm({
|
||||
} | null>(null);
|
||||
|
||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||
// Company name comes from the eTrade manager/owner name on the license.
|
||||
if (data.managerName) {
|
||||
setValue("companyName", data.managerName, { shouldValidate: true });
|
||||
if (data.companyName) {
|
||||
setValue("companyName", data.companyName, { shouldValidate: true });
|
||||
}
|
||||
setValue("licenceNumber", data.licenceNumber);
|
||||
setValue("statusDescription", data.statusDescription);
|
||||
|
||||
@@ -392,7 +392,7 @@ export default function NewContractPage({
|
||||
[profileStatusByType, profileTypes],
|
||||
);
|
||||
|
||||
// Create-profile modal state (license upload → createProfileAndSwitch).
|
||||
// Create-profile modal state (license upload → createProfile).
|
||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||
null,
|
||||
);
|
||||
@@ -416,7 +416,7 @@ export default function NewContractPage({
|
||||
type: ProfileTypeValue;
|
||||
files: File[];
|
||||
}) => {
|
||||
const res = await auth.createProfileAndSwitch(type, files);
|
||||
const res = await auth.createProfile(type, files);
|
||||
if (!res.success) {
|
||||
throw new Error(res.error?.message ?? "Failed to create profile");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import RoleCard from "./RoleCard";
|
||||
import { rolesForCompanyType } from "./companyRoles";
|
||||
@@ -18,6 +19,32 @@ interface CompanyRolesCardProps {
|
||||
profile: ProfileResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* How an existing role reads on the card. Only an `active` role is "approved" —
|
||||
* anything else is locked (a profile already exists, so it cannot be re-added)
|
||||
* but must never be presented as granted.
|
||||
*/
|
||||
function roleStatusView(p: CompanyProfileResponse): {
|
||||
note: string;
|
||||
color: string;
|
||||
approved: boolean;
|
||||
} {
|
||||
switch (p.status) {
|
||||
case "active":
|
||||
return { note: `Active · ${p.reference}`, color: "edr-green", approved: true };
|
||||
case "pending":
|
||||
return { note: "Pending review", color: "yellow.7", approved: false };
|
||||
case "rejected":
|
||||
return { note: "Rejected", color: "red.7", approved: false };
|
||||
case "suspended":
|
||||
return { note: "Suspended", color: "orange.7", approved: false };
|
||||
case "blacklisted":
|
||||
return { note: "Blacklisted", color: "red.7", approved: false };
|
||||
default:
|
||||
return { note: p.status, color: "edr-muted", approved: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -26,17 +53,18 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
[profile.companyType],
|
||||
);
|
||||
|
||||
// Roles already persisted (active + locked), keyed by type -> reference.
|
||||
const activeByType = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
|
||||
// Roles already persisted, keyed by type. Existence locks the card; the
|
||||
// profile's own status decides how it is labelled.
|
||||
const profileByType = useMemo(() => {
|
||||
const map = new Map<string, CompanyProfileResponse>();
|
||||
for (const p of profile.companyProfiles) map.set(p.type, p);
|
||||
return map;
|
||||
}, [profile.companyProfiles]);
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (type: string) => {
|
||||
if (activeByType.has(type)) return; // add-only: active roles are locked
|
||||
if (profileByType.has(type)) return; // add-only: existing roles are locked
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) next.delete(type);
|
||||
@@ -83,7 +111,8 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{options.map((opt) => {
|
||||
const isActive = activeByType.has(opt.type);
|
||||
const existing = profileByType.get(opt.type);
|
||||
const view = existing ? roleStatusView(existing) : undefined;
|
||||
return (
|
||||
<RoleCard
|
||||
key={opt.type}
|
||||
@@ -91,10 +120,10 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
description={opt.description}
|
||||
icon={opt.icon}
|
||||
selected={selected.has(opt.type)}
|
||||
locked={isActive}
|
||||
lockedNote={
|
||||
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
|
||||
}
|
||||
locked={Boolean(existing)}
|
||||
approved={view?.approved}
|
||||
lockedNote={view?.note}
|
||||
lockedNoteColor={view?.color}
|
||||
onClick={() => toggle(opt.type)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,17 @@ export interface RoleCardProps {
|
||||
icon: React.ReactNode;
|
||||
/** Highlighted because the user just selected it (toggleable). */
|
||||
selected?: boolean;
|
||||
/** Highlighted and non-interactive because it is already persisted. */
|
||||
/** Non-interactive because a profile for this role already exists. */
|
||||
locked?: boolean;
|
||||
/**
|
||||
* Approved by a reviewer. Drives the green "granted" treatment, which a
|
||||
* merely-locked role (e.g. still pending review) must not receive.
|
||||
*/
|
||||
approved?: boolean;
|
||||
/** Small note under the description, e.g. "Active · IM-00001". */
|
||||
lockedNote?: string;
|
||||
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||
lockedNoteColor?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -25,10 +32,12 @@ export default function RoleCard({
|
||||
icon,
|
||||
selected = false,
|
||||
locked = false,
|
||||
approved = false,
|
||||
lockedNote,
|
||||
lockedNoteColor = "edr-green",
|
||||
onClick,
|
||||
}: RoleCardProps) {
|
||||
const highlighted = selected || locked;
|
||||
const highlighted = selected || approved;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -37,8 +46,12 @@ export default function RoleCard({
|
||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||
highlighted
|
||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
} ${locked ? "cursor-default" : ""}`}
|
||||
: "border-edr-border! bg-edr-card!"
|
||||
} ${
|
||||
locked
|
||||
? "cursor-default"
|
||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
}`}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
@@ -58,7 +71,7 @@ export default function RoleCard({
|
||||
{description}
|
||||
</Text>
|
||||
{lockedNote && (
|
||||
<Text size="xs" c="edr-green" mt={6} fw={600}>
|
||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||
{lockedNote}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -385,7 +385,8 @@ function ProfileLicenseRow({
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={700} c="edr-text">
|
||||
{ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
|
||||
{ROLE_LABELS[profile.type] ?? profile.type}
|
||||
{profile.reference ? ` · ${profile.reference}` : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
|
||||
Reference in New Issue
Block a user