fix: the issue in the sheets file

This commit is contained in:
Nathnael
2026-07-10 12:10:17 +00:00
parent bd4d7b72fd
commit f737e401b3
23 changed files with 314 additions and 70 deletions

View File

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

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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).

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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");
}

View File

@@ -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)}
/>
);

View File

@@ -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>
)}

View File

@@ -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"