Compare commits
53 commits
0f72ffdae9
...
3ae130aa08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ae130aa08 | ||
|
|
73f639c271 | ||
|
|
403bc66383 | ||
|
|
dcc9429da8 | ||
|
|
3f3a3701c3 | ||
|
|
6e0967eaf0 | ||
|
|
73090d05e4 | ||
|
|
fe0d1a2571 | ||
|
|
7fed941435 | ||
|
|
c5816f8907 | ||
|
|
7de34d359e | ||
|
|
74dd1292a4 | ||
|
|
cadfe1ee8d | ||
|
|
551f6af208 | ||
|
|
a75b9c8be6 | ||
|
|
31e9db2d02 | ||
|
|
e6250fba88 | ||
|
|
5f8218b09a | ||
|
|
8a1a12bfa1 | ||
|
|
e052dcb37a | ||
|
|
358ec3e890 | ||
|
|
2084cfa453 | ||
|
|
43dd95e7fd | ||
|
|
9f46a91cb9 | ||
|
|
33f7172598 | ||
|
|
b91682f53c | ||
|
|
70f8b303b5 | ||
|
|
52cbb87126 | ||
|
|
d653ca0095 | ||
|
|
39ee98e09b | ||
|
|
ef6cde64c8 | ||
|
|
d573ab59a9 | ||
|
|
6012ebabe7 | ||
|
|
8bb4c0c512 | ||
|
|
62e38e4b81 | ||
|
|
b316168930 | ||
|
|
67cb490d52 | ||
|
|
cfe83fc489 | ||
|
|
f64447f52c | ||
|
|
7984640a5f | ||
|
|
792dc6917b | ||
|
|
c1fd8f6d88 | ||
|
|
c99e81d0d7 | ||
|
|
60b0638ea3 | ||
|
|
fca87b67cb | ||
|
|
11a753a2e9 | ||
|
|
ed931ea2eb | ||
|
|
51ca244524 | ||
|
|
b5da537c93 | ||
|
|
f69c8a04eb | ||
|
|
f65a48077d | ||
|
|
44e96cf2a3 | ||
|
|
0d6af0da59 |
|
|
@ -26,7 +26,7 @@
|
|||
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
|
||||
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
|
||||
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
|
||||
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
|
||||
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts |
|
||||
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
|
||||
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
|
||||
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 198 KiB |
|
|
@ -191,7 +191,11 @@ async function mockDashboardApi(page: Page, userRoles: string) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
|
||||
if (pathname.includes("/Dashboard/Stats")) {
|
||||
// The server reports no unassigned work for a dispatcher-scoped view.
|
||||
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
|
||||
return fulfillJson(route, { data: { ...STATS, unassigned } });
|
||||
}
|
||||
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
|
||||
if (pathname.includes("/Dashboard/Performance"))
|
||||
return fulfillJson(route, { data: PERFORMANCE });
|
||||
|
|
|
|||
2
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
|
|||
|
||||
await page.getByLabel("New total NTE").fill("125");
|
||||
await page.getByLabel("Reason").fill("Replacement compressor");
|
||||
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
|
||||
await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
|
||||
name: "estimate.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
|||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { getSidebarWidth } from "@/config/menu";
|
||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
|
|
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
|
|||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
<SlaAtRiskBanner />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
|
||||
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Text variant="label" tone="muted" className="text-[12px]!">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="m-0 text-[14px]!">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSummaryCard({ user }: { user: AuthUser }) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Account details"
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ReadOnlyValue label="Name" value={user.fullname} />
|
||||
<ReadOnlyValue label="Role" value={user.userRoles} />
|
||||
</div>
|
||||
<ReadOnlyValue label="Email" value={user.email} />
|
||||
<Text variant="caption" tone="muted" className="text-[11.5px]!">
|
||||
Contact your Admin to change your name, role, or email.
|
||||
</Text>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { PasswordField } from "@/components/ui/password-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
CURRENT_PASSWORD_INCORRECT_MESSAGE,
|
||||
changePasswordSchema,
|
||||
type ChangePasswordFormValues,
|
||||
} from "@/domain/auth/schemas/change-password-schema";
|
||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||
|
||||
const EMPTY_VALUES: ChangePasswordFormValues = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
|
||||
export function ChangePasswordSection() {
|
||||
const changePassword = useChangePassword();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<ChangePasswordFormValues>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const newPassword = values.newPassword ?? "";
|
||||
const canSubmit = changePasswordSchema.safeParse(values).success;
|
||||
const serverError = changePassword.error?.message;
|
||||
const showServerError =
|
||||
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
|
||||
|
||||
const onSubmit = (form: ChangePasswordFormValues) => {
|
||||
changePassword.mutate(
|
||||
{
|
||||
Currentpassword: form.currentPassword,
|
||||
Newpassword: form.newPassword,
|
||||
Confirmpassword: form.confirmPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: () => reset(EMPTY_VALUES),
|
||||
onError: (error) => {
|
||||
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
|
||||
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-labelledby="change-password-heading"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={15} aria-hidden="true" className="text-primary" />
|
||||
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
|
||||
Change Password
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Current password"
|
||||
htmlFor="account-current-password"
|
||||
required
|
||||
error={Boolean(errors.currentPassword)}
|
||||
helperText={errors.currentPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("currentPassword")}
|
||||
id="account-current-password"
|
||||
autoComplete="current-password"
|
||||
error={Boolean(errors.currentPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="New password" htmlFor="account-new-password" required>
|
||||
<PasswordField
|
||||
{...register("newPassword")}
|
||||
id="account-new-password"
|
||||
autoComplete="new-password"
|
||||
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
|
||||
/>
|
||||
<PasswordChecklist id="account-password-rules" password={newPassword} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm new password"
|
||||
htmlFor="account-confirm-password"
|
||||
required
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("confirmPassword")}
|
||||
id="account-confirm-password"
|
||||
autoComplete="new-password"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{showServerError && <Text variant="error">{serverError}</Text>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
loading={changePassword.isPending}
|
||||
loadingText="Updating…"
|
||||
>
|
||||
Update password
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
src/app/(protected)/account/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Box } from "@mui/material";
|
||||
|
||||
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
|
||||
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
export default function MyAccountPage() {
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-2 p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title="My Account"
|
||||
subtitle="Review your account and manage your password"
|
||||
/>
|
||||
<Box className="grid max-w-md gap-5 px-5 pb-5">
|
||||
{user != null && <AccountSummaryCard user={user} />}
|
||||
<ChangePasswordSection />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useNavigate } from "react-router";
|
||||
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
|
||||
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
|
||||
import PendingActionsIcon from "@mui/icons-material/PendingActions";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { Box } from "@mui/material";
|
||||
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
|
||||
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
|
||||
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
unassignedDrilldownSearch,
|
||||
UPLIFTS_ROUTE,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
interface DashboardKpiCardsProps {
|
||||
stats?: DashboardStats;
|
||||
loading: boolean;
|
||||
range: DashboardRangeKey;
|
||||
}
|
||||
|
||||
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
|
||||
const navigate = useNavigate();
|
||||
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
|
||||
const pendingUplifts = stats?.pendingUplifts ?? 0;
|
||||
const avetaPending = stats?.avetaPending ?? 0;
|
||||
const unassigned = stats?.unassigned ?? 0;
|
||||
|
||||
return (
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
title="Scheduled Tomorrow"
|
||||
icon={<EventOutlinedIcon />}
|
||||
tone="primary"
|
||||
value={stats?.scheduledTomorrow}
|
||||
loading={loading}
|
||||
active={scheduledTomorrow > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Pending Uplifts"
|
||||
icon={<PendingActionsIcon />}
|
||||
tone="info"
|
||||
value={stats?.pendingUplifts}
|
||||
loading={loading}
|
||||
active={pendingUplifts > 0}
|
||||
onSelect={() => navigate(UPLIFTS_ROUTE)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Aveta Pending"
|
||||
icon={<ShieldOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats?.avetaPending}
|
||||
loading={loading}
|
||||
active={avetaPending > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Unassigned"
|
||||
icon={<AssignmentLateOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats?.unassigned}
|
||||
loading={loading}
|
||||
active={unassigned > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { useNavigate } from "react-router";
|
||||
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
|
||||
import PendingActionsIcon from "@mui/icons-material/PendingActions";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
|
||||
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
|
||||
import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards";
|
||||
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
|
||||
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
|
||||
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
|
||||
|
|
@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st
|
|||
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
|
||||
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
UPLIFTS_ROUTE,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const scope = useDashboardScope();
|
||||
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
|
||||
|
||||
|
|
@ -62,35 +51,7 @@ export default function DashboardPage() {
|
|||
/>
|
||||
}
|
||||
/>
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<DashboardKpiCard
|
||||
title="Scheduled Tomorrow"
|
||||
icon={<EventOutlinedIcon />}
|
||||
tone="primary"
|
||||
value={stats.data?.scheduledTomorrow}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Pending Uplifts"
|
||||
icon={<PendingActionsIcon />}
|
||||
tone="info"
|
||||
value={stats.data?.pendingUplifts}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.pendingUplifts ?? 0) > 0}
|
||||
onSelect={() => navigate(UPLIFTS_ROUTE)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Aveta Pending"
|
||||
icon={<ShieldOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats.data?.avetaPending}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.avetaPending ?? 0) > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
|
||||
/>
|
||||
</Box>
|
||||
<DashboardKpiCards stats={stats.data} loading={stats.isLoading} range={range} />
|
||||
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<WorkOrderBreakdownCard
|
||||
breakdown={stats.data?.breakdown ?? []}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||
import {
|
||||
locationDefaultValues,
|
||||
locationSchema,
|
||||
type LocationFormValues,
|
||||
} from "@/domain/locations/schemas/location-schema";
|
||||
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||
import {
|
||||
DUPLICATE_SITE_CODE_MESSAGE,
|
||||
isDuplicateSiteCodeError,
|
||||
} from "@/domain/locations/utils/site-code-conflict";
|
||||
import { SiteFormFields } from "./site-form-fields";
|
||||
|
||||
interface AddSiteDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */
|
||||
export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) {
|
||||
const createLocation = useCreateLocation();
|
||||
const form = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: locationDefaultValues,
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
form.reset(locationDefaultValues);
|
||||
createLocation.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onSubmit = (values: LocationFormValues) => {
|
||||
createLocation.mutate(values, {
|
||||
onSuccess: close,
|
||||
onError: (error) => {
|
||||
if (isDuplicateSiteCodeError(error)) {
|
||||
form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!createLocation.isPending) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
aria-labelledby="add-site-title"
|
||||
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
|
||||
>
|
||||
<Box component="form" noValidate onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogTitle id="add-site-title">
|
||||
Add Site
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
className="block"
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
Create a new client site record.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<SiteFormFields form={form} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="text" onClick={close} disabled={createLocation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={createLocation.isPending}>
|
||||
{createLocation.isPending ? "Adding..." : "Add Site"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
64
src/app/(protected)/locations/_components/client-select.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Controller, type Control, type FieldError } from "react-hook-form";
|
||||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
type ClientOption = Pick<AccountListItem, "id" | "name">;
|
||||
|
||||
interface ClientSelectProps {
|
||||
control: Control<LocationFormValues>;
|
||||
error?: FieldError;
|
||||
/** Stored client, shown even when it is not in the first page of search results. */
|
||||
initialClient?: ClientOption;
|
||||
}
|
||||
|
||||
/** Searchable client (account) picker bound to `accountId`. */
|
||||
export function ClientSelect({ control, error, initialClient }: ClientSelectProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 });
|
||||
|
||||
const options = useMemo<ClientOption[]>(() => {
|
||||
const items: ClientOption[] = data?.items ?? [];
|
||||
const hasInitial =
|
||||
initialClient !== undefined &&
|
||||
items.some((item) => String(item.id) === String(initialClient.id));
|
||||
return initialClient === undefined || hasInitial ? items : [initialClient, ...items];
|
||||
}, [data?.items, initialClient]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete<ClientOption>
|
||||
options={options}
|
||||
loading={isLoading}
|
||||
getOptionLabel={(option) => option.name || String(option.id)}
|
||||
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||
value={options.find((option) => String(option.id) === field.value) ?? null}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
onBlur={field.onBlur}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
setSearch(value);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Client"
|
||||
required
|
||||
placeholder="Search clients..."
|
||||
error={Boolean(error)}
|
||||
helperText={error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ export function LocationContactsFieldArray({
|
|||
<Box ref={rootRef}>
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Contacts
|
||||
Points of contact
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
|
|
@ -210,7 +210,7 @@ export function LocationContactsFieldArray({
|
|||
startIcon={<AddIcon fontSize="small" />}
|
||||
onClick={() => append({ name: "", phone: "" })}
|
||||
>
|
||||
Add Contact
|
||||
Add point of contact
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack spacing={1.5} className="mt-2">
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ export function LocationDeleteDialog({
|
|||
}: LocationDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={Boolean(target)} onClose={onClose}>
|
||||
<DialogTitle>Delete Location</DialogTitle>
|
||||
<DialogTitle>Delete this site?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{target?.name}"? This action cannot be undone.
|
||||
This will permanently remove {target?.name} from Sites. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
|
||||
import {
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
import { LocationContactsFieldArray } from "./location-contacts-field-array";
|
||||
import { StateSelect } from "./state-select";
|
||||
|
||||
interface SharedFieldsProps {
|
||||
control: Control<LocationFormValues>;
|
||||
errors: FieldErrors<LocationFormValues>;
|
||||
register: UseFormRegister<LocationFormValues>;
|
||||
}
|
||||
|
||||
interface LocationDetailsFieldsProps extends SharedFieldsProps {
|
||||
accountOptions: AccountListItem[];
|
||||
isLoadingAccounts: boolean;
|
||||
onAccountSearchChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LocationDetailsFields({
|
||||
accountOptions,
|
||||
control,
|
||||
errors,
|
||||
isLoadingAccounts,
|
||||
onAccountSearchChange,
|
||||
register,
|
||||
}: LocationDetailsFieldsProps) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Location Information
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("name")}
|
||||
label="Name"
|
||||
required
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
loading={isLoadingAccounts}
|
||||
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
||||
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
onAccountSearchChange(value);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
{...register("address")}
|
||||
label="Address"
|
||||
required
|
||||
error={Boolean(errors.address)}
|
||||
helperText={errors.address?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("city")}
|
||||
label="City"
|
||||
required
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<StateSelect control={control} error={errors.state} />
|
||||
<TextField
|
||||
{...register("zipCode")}
|
||||
label="Zip Code"
|
||||
required
|
||||
error={Boolean(errors.zipCode)}
|
||||
helperText={errors.zipCode?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Contact Information
|
||||
</Typography>
|
||||
<LocationContactsFieldArray control={control} errors={errors} register={register} />
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("contactEmail")}
|
||||
label="Contact Email"
|
||||
type="email"
|
||||
required
|
||||
error={Boolean(errors.contactEmail)}
|
||||
helperText={errors.contactEmail?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required error={Boolean(errors.status)}>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
||||
{LOCATION_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||
import { normalizeStateCode } from "@/data/areas";
|
||||
import {
|
||||
locationDefaultValues,
|
||||
locationSchema,
|
||||
type LocationFormValues,
|
||||
} from "@/domain/locations/schemas/location-schema";
|
||||
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
|
||||
|
||||
export default function LocationFormPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const [accountSearch, setAccountSearch] = useState("");
|
||||
const debouncedAccountSearch = useDebounce(accountSearch, 300);
|
||||
|
||||
const { data: location, isLoading, error } = useLocationDetail(id);
|
||||
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
|
||||
search: debouncedAccountSearch,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
const createLocation = useCreateLocation();
|
||||
const updateLocation = useUpdateLocation();
|
||||
|
||||
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: locationDefaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (location && isEdit) {
|
||||
reset({
|
||||
name: location.name ?? "",
|
||||
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||
address: location.address ?? "",
|
||||
city: location.city ?? "",
|
||||
state: normalizeStateCode(location.state) ?? "",
|
||||
zipCode: location.zipCode ?? "",
|
||||
contacts:
|
||||
location.contacts && location.contacts.length > 0
|
||||
? location.contacts
|
||||
: [{ name: location.contact ?? "", phone: location.phone ?? "" }],
|
||||
contactEmail: location.contactEmail ?? "",
|
||||
status: (location.status as LocationFormValues["status"]) || "Active",
|
||||
});
|
||||
}
|
||||
}, [location, isEdit, reset]);
|
||||
|
||||
const isSaving = createLocation.isPending || updateLocation.isPending;
|
||||
|
||||
const onSubmit = (values: LocationFormValues) => {
|
||||
if (isEdit && id) {
|
||||
updateLocation.mutate({ id, values });
|
||||
return;
|
||||
}
|
||||
createLocation.mutate(values);
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="mx-auto max-w-4xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={4}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? "Edit Location" : "Create a Location"}
|
||||
</Typography>
|
||||
<Button variant="text" onClick={() => navigate("/locations")}>
|
||||
Back to list
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load location"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<LocationDetailsFields
|
||||
accountOptions={accountOptions}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isLoadingAccounts={isLoadingAccounts}
|
||||
onAccountSearchChange={setAccountSearch}
|
||||
register={register}
|
||||
/>
|
||||
<LocationContactFields control={control} errors={errors} register={register} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
|
|
@ -21,83 +20,100 @@ import { StateAreaCell } from "./state-area-cell";
|
|||
interface LocationsTableProps {
|
||||
isLoading: boolean;
|
||||
onDelete: (row: LocationListItem) => void;
|
||||
onEdit: (row: LocationListItem) => void;
|
||||
onOpen: (row: LocationListItem) => void;
|
||||
rows: LocationListItem[];
|
||||
selectedId?: string | null;
|
||||
}
|
||||
|
||||
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
|
||||
const COLUMN_COUNT = 8;
|
||||
|
||||
export function LocationsTable({
|
||||
isLoading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
rows,
|
||||
selectedId = null,
|
||||
}: LocationsTableProps) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Location Name</TableCell>
|
||||
<TableCell className="font-semibold">Address</TableCell>
|
||||
<TableCell className="font-semibold">Site Code</TableCell>
|
||||
<TableCell className="font-semibold">Client</TableCell>
|
||||
<TableCell className="font-semibold">Street Address</TableCell>
|
||||
<TableCell className="font-semibold">City</TableCell>
|
||||
<TableCell className="font-semibold">State</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Contact</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">POC Name</TableCell>
|
||||
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
)}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
|
||||
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>No sites found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
)}
|
||||
{!isLoading &&
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableRow
|
||||
key={String(row.id)}
|
||||
hover
|
||||
selected={String(row.id) === selectedId}
|
||||
onClick={() => onOpen(row)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => onEdit(row)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen(row);
|
||||
}}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>{row.address || "—"}</TableCell>
|
||||
<TableCell>{row.city || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<StateAreaCell state={row.state} />
|
||||
</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<LocationsContactsCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell>{row.status || "—"}</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete location"
|
||||
onClick={() => onDelete(row)}
|
||||
aria-label={`Delete site ${row.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(row);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box, Button, Stack } from "@mui/material";
|
||||
import {
|
||||
locationSchema,
|
||||
type LocationFormValues,
|
||||
} from "@/domain/locations/schemas/location-schema";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||
import { SiteFormFields } from "./site-form-fields";
|
||||
import { toSiteFormValues } from "./site-form-values";
|
||||
|
||||
interface SiteDetailEditProps {
|
||||
siteId: string;
|
||||
location: Location;
|
||||
/** Back to view mode: after Cancel (no API call) or after a successful save. */
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */
|
||||
export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) {
|
||||
const updateLocation = useUpdateLocation();
|
||||
const form = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: toSiteFormValues(location),
|
||||
mode: "onChange",
|
||||
});
|
||||
const { isDirty, isValid } = form.formState;
|
||||
const initialClient =
|
||||
location.accountId != null && location.clientName
|
||||
? { id: location.accountId, name: location.clientName }
|
||||
: undefined;
|
||||
|
||||
const onSubmit = (values: LocationFormValues) => {
|
||||
updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
aria-label="Edit site"
|
||||
>
|
||||
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<SiteFormFields form={form} siteCodeReadOnly initialClient={initialClient} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
className="border-t px-4 py-3"
|
||||
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||
>
|
||||
<Button variant="text" onClick={onDone} disabled={updateLocation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isDirty || !isValid || updateLocation.isPending}
|
||||
>
|
||||
{updateLocation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
115
src/app/(protected)/locations/_components/site-detail-panel.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { SiteDetailEdit } from "./site-detail-edit";
|
||||
import { SiteDetailView } from "./site-detail-view";
|
||||
|
||||
export const SITE_PANEL_WIDTH = 420;
|
||||
|
||||
interface SiteDetailPanelProps {
|
||||
/** Selected site id; `null` closes the panel. */
|
||||
siteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
className="border-b px-4 py-3"
|
||||
sx={{ alignItems: "flex-start", justifyContent: "space-between", borderColor: "divider" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" component="h2">
|
||||
{location?.name || "Site"}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{location?.clientName || " "}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconCloseButton label="Close site details" onClick={onClose} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHeader location={location} onClose={onClose} />
|
||||
{isLoading && (
|
||||
<Box className="flex justify-center p-8">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error" className="m-4">
|
||||
{error instanceof Error ? error.message : "Failed to load site"}
|
||||
</Alert>
|
||||
)}
|
||||
{location !== undefined && mode === "edit" && (
|
||||
<SiteDetailEdit siteId={siteId} location={location} onDone={() => setMode("view")} />
|
||||
)}
|
||||
{location !== undefined && mode === "view" && (
|
||||
<>
|
||||
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<SiteDetailView location={location} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
className="border-t px-4 py-3"
|
||||
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||
>
|
||||
<Button variant="contained" onClick={() => setMode("edit")}>
|
||||
Edit
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-modal site panel: the table stays interactive, so clicking another row swaps the
|
||||
* content in place. Escape closes it unless a popup inside already handled the key.
|
||||
*/
|
||||
export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) {
|
||||
const open = siteId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
variant="persistent"
|
||||
open={open}
|
||||
slotProps={{
|
||||
paper: {
|
||||
role: "region",
|
||||
"aria-label": "Site details",
|
||||
className: "flex h-full flex-col border-l shadow-lg",
|
||||
sx: { width: SITE_PANEL_WIDTH, maxWidth: "100vw" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{siteId !== null && <PanelBody key={siteId} siteId={siteId} onClose={onClose} />}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Link, Stack, Typography } from "@mui/material";
|
||||
import { getAreaForState } from "@/data/areas";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||
import { SiteMapPreview } from "./site-map-preview";
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function formatState(state?: string): string {
|
||||
const area = getAreaForState(state);
|
||||
const code = state?.trim() ?? "";
|
||||
if (!code) {
|
||||
return "—";
|
||||
}
|
||||
return area === null ? code : `${code} · Area: ${area}`;
|
||||
}
|
||||
|
||||
/** Read-only site details, in the prototype order. */
|
||||
export function SiteDetailView({ location }: { location: Location }) {
|
||||
const contacts = location.contacts ?? [];
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Field label="Site Code">{location.name || "—"}</Field>
|
||||
<Field label="Client">{location.clientName || "—"}</Field>
|
||||
<Field label="Street Address">
|
||||
{Boolean(location.address) && (
|
||||
<Link
|
||||
href={buildSiteMapsUrl(location)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
{location.address}
|
||||
</Link>
|
||||
)}
|
||||
{!location.address && "—"}
|
||||
</Field>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Field label="City">{location.city || "—"}</Field>
|
||||
<Field label="State">{formatState(location.state)}</Field>
|
||||
</Stack>
|
||||
<SiteMapPreview
|
||||
address={location.address}
|
||||
city={location.city}
|
||||
state={location.state}
|
||||
zipCode={location.zipCode}
|
||||
/>
|
||||
<Field label="Site Phone">{location.sitePhone || "—"}</Field>
|
||||
<Field label="Site Email">{location.contactEmail || "—"}</Field>
|
||||
<Field label="Points of contact">
|
||||
{contacts.length === 0 && "—"}
|
||||
<Stack component="ul" spacing={0.5} className="m-0 list-none p-0">
|
||||
{contacts.map((contact, index) => (
|
||||
<li key={contact.id ?? `${contact.name}-${index}`}>
|
||||
{index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name}
|
||||
{Boolean(contact.phone) && ` · ${contact.phone}`}
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
</Field>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
111
src/app/(protected)/locations/_components/site-form-fields.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { Stack, TextField } from "@mui/material";
|
||||
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
|
||||
import { normalizeStateCode } from "@/data/areas";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import { ClientSelect } from "./client-select";
|
||||
import { LocationContactsFieldArray } from "./location-contacts-field-array";
|
||||
import { SiteMapPreview } from "./site-map-preview";
|
||||
import { StateSelect } from "./state-select";
|
||||
|
||||
interface SiteFormFieldsProps {
|
||||
form: UseFormReturn<LocationFormValues>;
|
||||
/** Edit mode: Site Code is immutable after creation. */
|
||||
siteCodeReadOnly?: boolean;
|
||||
initialClient?: Pick<AccountListItem, "id" | "name">;
|
||||
}
|
||||
|
||||
/** Shared Add Site / edit-mode fields, in the prototype order. */
|
||||
export function SiteFormFields({
|
||||
form,
|
||||
siteCodeReadOnly = false,
|
||||
initialClient,
|
||||
}: SiteFormFieldsProps) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
||||
const { field: addressField } = useController({ control, name: "address" });
|
||||
const { field: cityField } = useController({ control, name: "city" });
|
||||
const { field: stateField } = useController({ control, name: "state" });
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value.toUpperCase())}
|
||||
label="Site Code"
|
||||
placeholder="e.g. AB12"
|
||||
required={!siteCodeReadOnly}
|
||||
disabled={siteCodeReadOnly}
|
||||
error={Boolean(errors.name)}
|
||||
helperText={
|
||||
errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
||||
<AddressAutocompleteField
|
||||
value={addressField.value}
|
||||
onInputChange={addressField.onChange}
|
||||
onSelect={(parts) => {
|
||||
// One pick fills all three, each through its own controller so the
|
||||
// form marks them dirty and revalidates (edit mode's Save depends on it).
|
||||
addressField.onChange(parts.street);
|
||||
cityField.onChange(parts.city);
|
||||
stateField.onChange(normalizeStateCode(parts.state) ?? "");
|
||||
}}
|
||||
label="Street Address"
|
||||
required
|
||||
requiredMarker
|
||||
error={Boolean(errors.address)}
|
||||
helperText={
|
||||
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
|
||||
}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...cityField}
|
||||
label="City"
|
||||
required
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<StateSelect control={control} error={errors.state} />
|
||||
</Stack>
|
||||
<SiteMapPreview address={address} city={city} state={state} />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("sitePhone")}
|
||||
label="Site Phone (optional)"
|
||||
placeholder="(000) 000-0000"
|
||||
error={Boolean(errors.sitePhone)}
|
||||
helperText={errors.sitePhone?.message}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
/>
|
||||
<TextField
|
||||
{...register("contactEmail")}
|
||||
label="Site Email (optional)"
|
||||
placeholder="site@company.com"
|
||||
type="email"
|
||||
error={Boolean(errors.contactEmail)}
|
||||
helperText={errors.contactEmail?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<LocationContactsFieldArray control={control} errors={errors} register={register} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { normalizeStateCode } from "@/data/areas";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
|
||||
/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */
|
||||
export function toSiteFormValues(location: Location): LocationFormValues {
|
||||
const contacts =
|
||||
location.contacts && location.contacts.length > 0
|
||||
? location.contacts
|
||||
: [{ name: location.contact ?? "", phone: location.phone ?? "" }];
|
||||
|
||||
return {
|
||||
name: location.name ?? "",
|
||||
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||
address: location.address ?? "",
|
||||
city: location.city ?? "",
|
||||
state: normalizeStateCode(location.state) ?? "",
|
||||
sitePhone: location.sitePhone ?? "",
|
||||
contactEmail: location.contactEmail ?? "",
|
||||
contacts,
|
||||
zipCode: location.zipCode ?? "",
|
||||
title: location.title ?? "",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
|
||||
|
||||
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
|
||||
|
||||
/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
|
||||
export function SiteMapPreview(props: SiteMapPreviewProps) {
|
||||
const complete = hasCompleteSiteAddress(props);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Location preview
|
||||
</Typography>
|
||||
{complete && (
|
||||
<Box
|
||||
component="iframe"
|
||||
title="Site location map"
|
||||
src={buildSiteMapEmbedUrl(props)}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="mt-1 block w-full rounded border-0"
|
||||
sx={{ height: 140 }}
|
||||
/>
|
||||
)}
|
||||
{!complete && (
|
||||
<Box
|
||||
className="mt-1 flex items-center justify-center rounded border border-dashed"
|
||||
sx={{ height: 140, borderColor: "divider" }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Enter an address to preview the location
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "../_components/location-form";
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -15,20 +14,24 @@ import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-locat
|
|||
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||
import { AddSiteDialog } from "./_components/add-site-dialog";
|
||||
import { AreaFacet } from "./_components/area-facet";
|
||||
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
||||
import { LocationsTable } from "./_components/locations-table";
|
||||
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
||||
|
||||
export default function LocationsListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const deleteLocation = useDeleteLocation();
|
||||
const closePanel = useCallback(() => setSelectedId(null), []);
|
||||
|
||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||
|
||||
|
|
@ -52,13 +55,20 @@ export default function LocationsListPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
const deletedId = String(deleteTarget.id);
|
||||
deleteLocation.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
setSelectedId((current) => (current === deletedId ? null : current));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box
|
||||
className="flex flex-col gap-4 p-4"
|
||||
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
|
|
@ -67,34 +77,17 @@ export default function LocationsListPage() {
|
|||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Locations
|
||||
Sites
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
|
||||
<AreaFacet
|
||||
value={selectedAreas}
|
||||
onChange={(zones) => {
|
||||
|
|
@ -109,23 +102,24 @@ export default function LocationsListPage() {
|
|||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search locations..."
|
||||
placeholder="Search sites..."
|
||||
className="min-w-[220px]"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/locations/new")}>
|
||||
+ New Location
|
||||
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
|
||||
Add Site
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||
{error instanceof Error ? error.message : "Failed to load sites"}
|
||||
</Alert>
|
||||
)}
|
||||
<LocationsTable
|
||||
isLoading={isLoading}
|
||||
rows={rows}
|
||||
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
|
||||
selectedId={selectedId}
|
||||
onOpen={(row) => setSelectedId(String(row.id))}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
<TablePagination
|
||||
|
|
@ -140,6 +134,8 @@ export default function LocationsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||
<LocationDeleteDialog
|
||||
target={deleteTarget}
|
||||
isPending={deleteLocation.isPending}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./_components/location-form";
|
||||
|
|
@ -5,10 +5,12 @@ import {
|
|||
} from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type SlideOverCompletionMediaProps = {
|
||||
isReadOnly: boolean;
|
||||
media: MediaFile[];
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
mediaPending: boolean;
|
||||
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||
|
|
@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = {
|
|||
export function SlideOverCompletionMedia({
|
||||
isReadOnly,
|
||||
media,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
onUploadMedia,
|
||||
onCategorizeMedia,
|
||||
|
|
@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
|
|||
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||
<MediaUploader
|
||||
uploads={media}
|
||||
mediaCounts={mediaCounts}
|
||||
onUpload={onUploadMedia}
|
||||
onCategorize={onCategorizeMedia}
|
||||
onRemove={onRemoveMedia}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
|
||||
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
||||
|
|
@ -23,6 +24,7 @@ type SlideOverCompletionTabProps = {
|
|||
isReadOnly: boolean;
|
||||
completion?: WorkOrderBoardCompletion;
|
||||
media: MediaFile[];
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
mediaPending?: boolean;
|
||||
completionPending?: boolean;
|
||||
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||
|
|
@ -39,6 +41,7 @@ export function SlideOverCompletionTab({
|
|||
isReadOnly,
|
||||
completion,
|
||||
media,
|
||||
mediaCounts,
|
||||
mediaPending = false,
|
||||
completionPending = false,
|
||||
onUploadMedia,
|
||||
|
|
@ -59,6 +62,7 @@ export function SlideOverCompletionTab({
|
|||
<SlideOverCompletionMedia
|
||||
isReadOnly={isReadOnly}
|
||||
media={media}
|
||||
mediaCounts={mediaCounts}
|
||||
mediaPending={mediaPending}
|
||||
onUploadMedia={onUploadMedia}
|
||||
onCategorizeMedia={onCategorizeMedia}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
|
|
@ -18,12 +18,16 @@ import {
|
|||
hasExplicitAvetaFile,
|
||||
resolveExtraDocCategory,
|
||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
countWorkOrderMediaKinds,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -53,11 +57,16 @@ type SlideOverExtrasTabProps = {
|
|||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
/** Photos/videos on the whole work order; Extra Docs shows only its own share. */
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE =
|
||||
"Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.";
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
|
|
@ -176,27 +185,38 @@ export function SlideOverExtrasTab({
|
|||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
mediaCounts,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: SlideOverExtrasTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
const busy = pending || screening;
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||
// Snapshot synchronously: the input is reset right after this handler returns.
|
||||
const selected = Array.from(files);
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length > 0) {
|
||||
onUpload(accepted);
|
||||
}
|
||||
|
|
@ -228,12 +248,12 @@ export function SlideOverExtrasTab({
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
void addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
|
|
@ -245,13 +265,13 @@ export function SlideOverExtrasTab({
|
|||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span
|
||||
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||
aria-busy={pending || undefined}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
{busy ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
||||
MB and 90 s · Docs up to 50 MB
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
@ -260,9 +280,9 @@ export function SlideOverExtrasTab({
|
|||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
addFiles(event.target.files);
|
||||
void addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -130,6 +131,9 @@ export function useSlideOverMediaState({
|
|||
[detailForRow?.media, ownership, completionHasGallery],
|
||||
);
|
||||
|
||||
// Per-work-order photo/video counts span both tabs, so count the whole session.
|
||||
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
const mediaPending =
|
||||
mediaBatchPending ||
|
||||
|
|
@ -196,6 +200,7 @@ export function useSlideOverMediaState({
|
|||
return {
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({
|
|||
const {
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
|
|
@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({
|
|||
...readFlags,
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
completionPending,
|
||||
closeDisabled,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({
|
|||
isReadOnly={state.isDocumentsReadOnly}
|
||||
completion={state.detailForRow?.completion}
|
||||
media={state.completionMedia}
|
||||
mediaCounts={state.mediaCounts}
|
||||
mediaPending={state.mediaPending}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
|
|
@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
mediaCounts={state.mediaCounts}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
|
|||
key={option.key}
|
||||
label={option.label}
|
||||
selected={draft.dateRange === option.key}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
dateRange: prev.dateRange === option.key ? null : option.key,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<AdvFilterPill
|
||||
|
|
|
|||
|
|
@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
||||
<AdvSection
|
||||
icon={CheckCircle2}
|
||||
label="Status"
|
||||
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
||||
>
|
||||
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
|
|
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
|
|||
onClick={() => toggleIn("statuses", status)}
|
||||
/>
|
||||
))}
|
||||
<AdvStatusPill
|
||||
label="Past Due"
|
||||
dotColor="var(--status-pastdue-text)"
|
||||
selected={draft.pastDue}
|
||||
selectedBg="var(--status-pastdue-bg)"
|
||||
selectedText="var(--status-pastdue-text)"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
|
|
@ -40,6 +40,5 @@ export function resolveLocationId(
|
|||
}
|
||||
|
||||
export function buildMapsUrl(location: Location): string {
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||
return buildSiteMapsUrl(location);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { RefObject } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
MAX_PHOTOS_PER_WORK_ORDER,
|
||||
MAX_VIDEOS_PER_WORK_ORDER,
|
||||
MAX_VIDEO_DURATION_SECONDS,
|
||||
MAX_WORK_ORDER_PHOTO_BYTES,
|
||||
MAX_WORK_ORDER_VIDEO_BYTES,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
|
|
@ -12,6 +16,12 @@ type MediaUploaderDropzoneProps = {
|
|||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
function mediaContractHint(): string {
|
||||
const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000);
|
||||
const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000);
|
||||
return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`;
|
||||
}
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
|
|
@ -41,7 +51,7 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
countWorkOrderMediaKinds,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
|
|
@ -30,8 +34,12 @@ type MediaUploaderProps = {
|
|||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
pending?: boolean;
|
||||
/** Photos/videos on the whole work order when `uploads` is only one surface's share. */
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV.";
|
||||
|
||||
export function MediaUploader({
|
||||
uploads,
|
||||
onUpload,
|
||||
|
|
@ -42,24 +50,35 @@ export function MediaUploader({
|
|||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
mediaCounts,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
// Snapshot synchronously: the caller resets the input value right after this
|
||||
// handler returns, which can detach a live FileList across awaits.
|
||||
const selected = Array.from(files);
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
|
|
@ -93,9 +112,9 @@ export function MediaUploader({
|
|||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
pending={pending || screening}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
onFilesSelected={(files) => void addFiles(files)}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text";
|
|||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
function buildMapsUrl(location: Location): string {
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
type WizardStepLocationSiteDetailsProps = {
|
||||
selectedLoc: LocationOption | undefined;
|
||||
locationName: string;
|
||||
|
|
@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({
|
|||
)}
|
||||
{Boolean(addressPreview) && (
|
||||
<a
|
||||
href={buildMapsUrl(locationDetail)}
|
||||
href={buildSiteMapsUrl(locationDetail)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
|||
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
clearAdvancedFilterGroup,
|
||||
cloneAdvancedFilters,
|
||||
countAdvancedFilters,
|
||||
getInitialAdvancedFilters,
|
||||
resolveAppliedAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||
|
|
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
|
|||
};
|
||||
|
||||
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
||||
setAdvApplied(cloneAdvancedFilters(filters));
|
||||
setAdvApplied(resolveAppliedAdvancedFilters(filters));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function DispatchDetailSections({
|
|||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
documents={data.documents ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
|
|
@ -68,6 +69,7 @@ export function DispatchDetailSections({
|
|||
dispatchId={dispatchId}
|
||||
currentNte={data.nteAmount}
|
||||
upliftRequests={data.upliftRequests ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
pollDocument={pollEvidenceDocument}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
|
@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
|
|||
onWithdraw: (request: VendorPortalUpliftRequest) => void;
|
||||
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
|
||||
onCancelRevise: () => void;
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
};
|
||||
|
||||
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
|
||||
|
|
@ -23,6 +25,21 @@ function statusClassName(status: string): string {
|
|||
return `vp-uplift-status-${String(status).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) {
|
||||
// Requests raised from the work order are visible but read-only to the vendor.
|
||||
const actionable = !locked && canAct && request.raisedByVendor !== false;
|
||||
return {
|
||||
canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status),
|
||||
canRevise: actionable && request.status === "ChangesRequested",
|
||||
};
|
||||
}
|
||||
|
||||
// A vendor request stores the requested new NTE total; a work-order request stores the increase.
|
||||
function requestedTotal(request: VendorPortalUpliftRequest): number {
|
||||
const requested = Number(request.requestedNTE);
|
||||
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;
|
||||
}
|
||||
|
||||
export function UpliftRequestCard({
|
||||
request,
|
||||
locked,
|
||||
|
|
@ -33,18 +50,17 @@ export function UpliftRequestCard({
|
|||
onWithdraw,
|
||||
onRevise,
|
||||
onCancelRevise,
|
||||
mediaUsage,
|
||||
}: UpliftRequestCardProps) {
|
||||
const [revising, setRevising] = useState(false);
|
||||
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
|
||||
const canRevise = !locked && canAct && request.status === "ChangesRequested";
|
||||
const { canWithdraw, canRevise } = availableActions(request, locked, canAct);
|
||||
|
||||
return (
|
||||
<div className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>Requested</strong>
|
||||
<span>
|
||||
${Number(request.requestedNTE).toFixed(2)} (was $
|
||||
{Number(request.currentNTE ?? 0).toFixed(2)})
|
||||
${requestedTotal(request).toFixed(2)} (was ${Number(request.currentNTE ?? 0).toFixed(2)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
|
|
@ -100,6 +116,7 @@ export function UpliftRequestCard({
|
|||
initialRequested={String(request.requestedNTE)}
|
||||
initialReason={request.vendorReason ?? ""}
|
||||
hasExistingEvidence={request.evidenceDocumentId != null}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={uploadingPercent}
|
||||
errorMessage=""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
VENDOR_UPLOAD_ACCEPT,
|
||||
type VendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
isApprovalRequired,
|
||||
validateUpliftAmount,
|
||||
|
|
@ -13,6 +17,8 @@ type UpliftRequestFormProps = {
|
|||
initialRequested?: string;
|
||||
initialReason?: string;
|
||||
hasExistingEvidence?: boolean;
|
||||
/** Work-order photo/video usage for the evidence pre-check. */
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
submitting: boolean;
|
||||
uploadingPercent: number | undefined;
|
||||
errorMessage: string;
|
||||
|
|
@ -26,6 +32,7 @@ export function UpliftRequestForm({
|
|||
initialRequested = "",
|
||||
initialReason = "",
|
||||
hasExistingEvidence = false,
|
||||
mediaUsage,
|
||||
submitting,
|
||||
uploadingPercent,
|
||||
errorMessage,
|
||||
|
|
@ -36,11 +43,15 @@ export function UpliftRequestForm({
|
|||
const [reason, setReason] = useState(initialReason);
|
||||
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
|
||||
const [formError, setFormError] = useState("");
|
||||
const [checkingEvidence, setCheckingEvidence] = useState(false);
|
||||
const latestSelection = useRef(0);
|
||||
|
||||
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
// The picked file is not in state until its check settles; submitting now would validate the old one.
|
||||
if (checkingEvidence) return;
|
||||
const amount = Number.parseFloat(requested);
|
||||
const validationError = validateUpliftAmount(requested, currentNte, reason, {
|
||||
file: evidenceFile,
|
||||
|
|
@ -54,10 +65,15 @@ export function UpliftRequestForm({
|
|||
onSubmit({ amount, reason: reason.trim(), evidenceFile });
|
||||
};
|
||||
|
||||
const chooseFile = (file?: File) => {
|
||||
const validationMessage = file ? validateVendorDocument(file) : undefined;
|
||||
const chooseFile = async (file?: File) => {
|
||||
const selection = ++latestSelection.current;
|
||||
setCheckingEvidence(Boolean(file));
|
||||
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
|
||||
// A newer pick may have settled while this one's video metadata was still loading.
|
||||
if (selection !== latestSelection.current) return;
|
||||
setFormError(validationMessage ?? "");
|
||||
setEvidenceFile(file);
|
||||
setCheckingEvidence(false);
|
||||
};
|
||||
|
||||
const shownError = errorMessage || formError;
|
||||
|
|
@ -112,14 +128,16 @@ export function UpliftRequestForm({
|
|||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>
|
||||
<label htmlFor={`uplift-evidence-${mode}`}>Evidence (PDF/JPG/PNG)</label>
|
||||
<label htmlFor={`uplift-evidence-${mode}`}>
|
||||
Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)
|
||||
</label>
|
||||
</strong>
|
||||
<input
|
||||
id={`uplift-evidence-${mode}`}
|
||||
type="file"
|
||||
className="vp-input"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
onChange={(event) => chooseFile(event.target.files?.[0])}
|
||||
accept={VENDOR_UPLOAD_ACCEPT}
|
||||
onChange={(event) => void chooseFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -134,8 +152,14 @@ export function UpliftRequestForm({
|
|||
{shownError}
|
||||
</Text>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
|
||||
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy || checkingEvidence}>
|
||||
{busy
|
||||
? "Submitting…"
|
||||
: checkingEvidence
|
||||
? "Checking evidence…"
|
||||
: mode === "revise"
|
||||
? "Submit revision"
|
||||
: "Request uplift"}
|
||||
</button>
|
||||
{mode === "revise" && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|||
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
|
||||
import type {
|
||||
VendorPortalDocumentStatus,
|
||||
VendorPortalMediaCounts,
|
||||
VendorPortalUpliftRequest,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
|
||||
import {
|
||||
useVendorUpliftEvidence,
|
||||
|
|
@ -24,6 +26,7 @@ type UpliftRequestsSectionProps = {
|
|||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
};
|
||||
|
||||
type UseVendorUpliftActionsArgs = {
|
||||
|
|
@ -74,7 +77,7 @@ function useVendorUpliftActions({
|
|||
if (requiresApproval) {
|
||||
if (!values.evidenceFile) {
|
||||
setFormError(
|
||||
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
|
||||
"Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -168,7 +171,9 @@ export function UpliftRequestsSection({
|
|||
locked,
|
||||
onChanged,
|
||||
pollDocument,
|
||||
mediaCounts,
|
||||
}: UpliftRequestsSectionProps) {
|
||||
const mediaUsage = vendorMediaUsage(mediaCounts, "evidence");
|
||||
const evidence = useVendorUpliftEvidence({ pollDocument });
|
||||
const {
|
||||
submitting,
|
||||
|
|
@ -205,6 +210,7 @@ export function UpliftRequestsSection({
|
|||
onWithdraw={(r) => void handleWithdraw(r)}
|
||||
onRevise={(r, values) => void handleRevise(r, values)}
|
||||
onCancelRevise={evidence.reset}
|
||||
mediaUsage={mediaUsage}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -212,6 +218,7 @@ export function UpliftRequestsSection({
|
|||
<UpliftRequestForm
|
||||
mode="create"
|
||||
currentNte={currentNte}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={evidence.uploadingPercent}
|
||||
errorMessage={formError}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,83 @@ import { useMutation } from "@tanstack/react-query";
|
|||
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
VENDOR_UPLOAD_ACCEPT,
|
||||
vendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type {
|
||||
VendorPortalDocument,
|
||||
VendorPortalMediaCounts,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type VendorPortalDocumentsProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
documents: VendorPortalDocument[];
|
||||
locked: boolean;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
onChanged: () => Promise<void>;
|
||||
};
|
||||
|
||||
type VendorPortalDocumentRowProps = {
|
||||
document: VendorPortalDocument;
|
||||
locked: boolean;
|
||||
uploading: boolean;
|
||||
onDownload: () => void;
|
||||
onReplace: () => void;
|
||||
};
|
||||
|
||||
function VendorPortalDocumentRow({
|
||||
document,
|
||||
locked,
|
||||
uploading,
|
||||
onDownload,
|
||||
onReplace,
|
||||
}: VendorPortalDocumentRowProps) {
|
||||
return (
|
||||
<article className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={onDownload}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploading}
|
||||
onClick={onReplace}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type UploadRequest = {
|
||||
file: File;
|
||||
replacementId?: number;
|
||||
|
|
@ -24,12 +90,15 @@ export function VendorPortalDocuments({
|
|||
dispatchId,
|
||||
documents,
|
||||
locked,
|
||||
mediaCounts,
|
||||
onChanged,
|
||||
}: VendorPortalDocumentsProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [replacementId, setReplacementId] = useState<number>();
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
|
||||
const selectionRef = useRef(0);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||
|
|
@ -51,18 +120,27 @@ export function VendorPortalDocuments({
|
|||
});
|
||||
|
||||
const chooseFile = (documentId?: number) => {
|
||||
selectionRef.current += 1;
|
||||
setValidationError(undefined);
|
||||
uploadMutation.reset();
|
||||
setReplacementId(documentId);
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const uploadSelectedFile = (file?: File) => {
|
||||
const uploadSelectedFile = async (file?: File) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationMessage = validateVendorDocument(file);
|
||||
const selection = ++selectionRef.current;
|
||||
const replaced = documents.find((document) => document.id === replacementId);
|
||||
const validationMessage = await validateVendorDocument(
|
||||
file,
|
||||
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
|
||||
);
|
||||
if (selection !== selectionRef.current) {
|
||||
return;
|
||||
}
|
||||
if (validationMessage) {
|
||||
setValidationError(validationMessage);
|
||||
return;
|
||||
|
|
@ -83,9 +161,9 @@ export function VendorPortalDocuments({
|
|||
ref={inputRef}
|
||||
type="file"
|
||||
aria-label="Choose completion document"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
accept={VENDOR_UPLOAD_ACCEPT}
|
||||
hidden
|
||||
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
|
||||
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{documents.length === 0 && (
|
||||
|
|
@ -93,45 +171,14 @@ export function VendorPortalDocuments({
|
|||
)}
|
||||
|
||||
{documents.map((document) => (
|
||||
<article key={document.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => chooseFile(document.id)}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
<VendorPortalDocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
locked={locked}
|
||||
uploading={uploadMutation.isPending}
|
||||
onDownload={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
onReplace={() => chooseFile(document.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{progress !== undefined && (
|
||||
|
|
@ -155,6 +202,10 @@ export function VendorPortalDocuments({
|
|||
>
|
||||
Upload document
|
||||
</button>
|
||||
<span className="vp-hint">
|
||||
Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and
|
||||
90 s · PDFs up to 10 MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -190,6 +190,13 @@
|
|||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vp-hint {
|
||||
align-self: center;
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.vp-checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
63
src/components/auth/password-checklist.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from "react";
|
||||
import { Circle, CircleCheck } from "lucide-react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PasswordChecklistProps = {
|
||||
password: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
|
||||
return next
|
||||
.filter((rule, index) => rule.met !== previous[index]?.met)
|
||||
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
|
||||
.join(". ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, per-rule feedback for the shared password policy. A polite status region
|
||||
* announces only the rules whose state changed, so screen-reader users hear
|
||||
* progress while typing without the whole list being re-read on each keystroke.
|
||||
*/
|
||||
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
|
||||
const results = evaluatePasswordRules(password);
|
||||
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
|
||||
const [previous, setPrevious] = useState({ signature, results });
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
if (previous.signature !== signature) {
|
||||
setPrevious({ signature, results });
|
||||
setAnnouncement(describeChanges(previous.results, results));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul
|
||||
id={id}
|
||||
aria-label="Password requirements"
|
||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
||||
>
|
||||
{results.map(({ id: ruleId, label, met }) => (
|
||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
||||
{met ? (
|
||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
||||
)}
|
||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text variant="feedback" className="sr-only">
|
||||
{announcement}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
|
|||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
/**
|
||||
* Show MUI's required asterisk on the label. Sites marks required fields that
|
||||
* way; Vendors spells "(required)" in the label and asserts no asterisk.
|
||||
*/
|
||||
requiredMarker?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
|
|||
label = "Street Address",
|
||||
placeholder = "Start typing the street address…",
|
||||
required = false,
|
||||
requiredMarker = false,
|
||||
error = false,
|
||||
helperText,
|
||||
disabled = false,
|
||||
|
|
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
|
|||
error={error}
|
||||
helperText={helperText}
|
||||
fullWidth
|
||||
// Required is set on the input itself, never via MUI's `required`
|
||||
// prop: this codebase spells the requirement in the label text and
|
||||
// asserts that no generated asterisk renders anywhere.
|
||||
required={requiredMarker}
|
||||
// Required is set on the input itself; MUI's `required` prop only
|
||||
// adds the label asterisk, which Vendors must not render.
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
import { CircleUserRound } from "lucide-react";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||
|
||||
const ACCOUNT_PATH = "/account";
|
||||
|
||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||
if (isActive) {
|
||||
return {
|
||||
|
|
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Account navigation"
|
||||
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
|
||||
>
|
||||
<NavItem
|
||||
label="My Account"
|
||||
icon={CircleUserRound}
|
||||
path={ACCOUNT_PATH}
|
||||
collapsed={collapsed}
|
||||
active={pathname === ACCOUNT_PATH}
|
||||
onNavigate={mobile ? onClose : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
|
||||
import type { NotificationCenter } from "./use-notification-center";
|
||||
|
||||
type NotificationFeedActionsProps = {
|
||||
|
|
@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
|
|||
|
||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||
const hasUnread = center.unreadIds.size > 0;
|
||||
const hasItems = center.sections.length > 0;
|
||||
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
|
||||
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
|||
unread={center.unreadIds.has(item.id)}
|
||||
onOpen={center.open}
|
||||
onDismiss={center.dismiss}
|
||||
onAcknowledge={center.acknowledge}
|
||||
acknowledging={
|
||||
item.target.workOrderId != null &&
|
||||
item.target.workOrderId === center.acknowledgingWorkOrderId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -53,8 +53,37 @@ type NotificationRowProps = {
|
|||
unread: boolean;
|
||||
onOpen: (item: NotificationItem, url?: string) => void;
|
||||
onDismiss: (item: NotificationItem) => void;
|
||||
onAcknowledge?: (item: NotificationItem) => void;
|
||||
acknowledging?: boolean;
|
||||
};
|
||||
|
||||
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
|
||||
function AcknowledgeButton({
|
||||
item,
|
||||
onAcknowledge,
|
||||
acknowledging = false,
|
||||
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcknowledge?.(item)}
|
||||
disabled={acknowledging}
|
||||
title="Acknowledge"
|
||||
aria-label="Acknowledge SLA breach"
|
||||
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
color: "var(--destructive)",
|
||||
borderColor: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
{acknowledging ? "Acknowledging…" : "Acknowledge"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
||||
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
||||
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
||||
|
|
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
|
|||
);
|
||||
}
|
||||
|
||||
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
||||
export function NotificationRow({
|
||||
item,
|
||||
unread,
|
||||
onOpen,
|
||||
onDismiss,
|
||||
onAcknowledge,
|
||||
acknowledging,
|
||||
}: NotificationRowProps) {
|
||||
return (
|
||||
<li
|
||||
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
|
|
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
|
|||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{item.rowType === "acknowledge" && (
|
||||
<AcknowledgeButton
|
||||
item={item}
|
||||
onAcknowledge={onAcknowledge}
|
||||
acknowledging={acknowledging}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
90
src/components/notifications/sla-at-risk-banner.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { AlertTriangle, ChevronRight, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
|
||||
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
|
||||
|
||||
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
|
||||
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
|
||||
const { claimSlaToasts } = useNotificationSession();
|
||||
|
||||
useEffect(() => {
|
||||
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
|
||||
atRisk
|
||||
.filter((workOrder) => claimed.has(workOrder.id))
|
||||
.forEach((workOrder) =>
|
||||
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
|
||||
);
|
||||
}, [atRisk, claimSlaToasts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
|
||||
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
|
||||
* hides them for this session, and a work order that becomes at risk later brings the banner back.
|
||||
*/
|
||||
export function SlaAtRiskBanner() {
|
||||
const navigate = useNavigate();
|
||||
const { data } = useNotificationFeed();
|
||||
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
|
||||
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
|
||||
useSlaHalfwayToasts(atRisk);
|
||||
|
||||
const shown = useMemo(
|
||||
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
|
||||
[atRisk, slaBannerDismissedIds],
|
||||
);
|
||||
if (shown.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ids = shown.map((workOrder) => workOrder.id);
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Work orders at risk of missing their response deadline"
|
||||
className="flex shrink-0 items-center"
|
||||
style={{
|
||||
gap: 10,
|
||||
padding: "6px 16px",
|
||||
background: "var(--destructive)",
|
||||
color: "var(--destructive-foreground)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
|
||||
title="View in Work Orders"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
|
||||
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
|
||||
>
|
||||
<AlertTriangle size={14} className="shrink-0" aria-hidden />
|
||||
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
|
||||
{slaBannerText(shown)}
|
||||
</span>
|
||||
<ChevronRight size={14} className="shrink-0" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissSlaBanner(ids)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss SLA alert"
|
||||
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
|
||||
style={{ width: 22, height: 22, color: "inherit" }}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||
import {
|
||||
|
|
@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
|||
[items, readIds],
|
||||
);
|
||||
|
||||
const acknowledgeBreach = useAcknowledgeSlaBreach();
|
||||
const acknowledge = (item: NotificationItem) => {
|
||||
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
|
||||
acknowledgeBreach.mutate(item.target.workOrderId);
|
||||
};
|
||||
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
|
||||
|
||||
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
||||
markRead([item.id]);
|
||||
if (url == null) return;
|
||||
|
|
@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
|||
error: feed.error,
|
||||
open,
|
||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||
acknowledge,
|
||||
acknowledgingWorkOrderId,
|
||||
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
||||
markAllRead: () => markRead(items.map((item) => item.id)),
|
||||
};
|
||||
|
|
|
|||
53
src/domain/auth/password-policy.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The single password rule for every surface that sets a password. It mirrors the
|
||||
* server's Identity options: the character classes are ASCII, matching how the
|
||||
* server classifies uppercase letters, digits and non-alphanumeric characters.
|
||||
*/
|
||||
export const PASSWORD_MIN_LENGTH = 6;
|
||||
|
||||
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
|
||||
|
||||
export type PasswordRule = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
test: (password: string) => boolean;
|
||||
};
|
||||
|
||||
export type PasswordRuleResult = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
met: boolean;
|
||||
};
|
||||
|
||||
export const PASSWORD_RULES: readonly PasswordRule[] = [
|
||||
{
|
||||
id: "length",
|
||||
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
|
||||
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
|
||||
},
|
||||
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
|
||||
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
|
||||
{
|
||||
id: "special",
|
||||
label: "One special character",
|
||||
test: (password) => /[^A-Za-z0-9]/.test(password),
|
||||
},
|
||||
];
|
||||
|
||||
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
|
||||
|
||||
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
|
||||
|
||||
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
|
||||
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
|
||||
}
|
||||
|
||||
export function meetsPasswordPolicy(password: string): boolean {
|
||||
return PASSWORD_RULES.every((rule) => rule.test(password));
|
||||
}
|
||||
|
||||
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
|
||||
message: PASSWORD_REQUIREMENTS_MESSAGE,
|
||||
});
|
||||
18
src/domain/auth/schemas/change-password-schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
||||
|
||||
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
|
||||
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((values) => values.newPassword === values.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
||||
});
|
||||
|
||||
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
||||
|
|
@ -3,14 +3,13 @@ import { toast } from "react-toastify";
|
|||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
||||
|
||||
/** Errors are rendered inline by the form that initiated the change. */
|
||||
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
||||
return useMutation({
|
||||
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed successfully!");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
toast.success("Password updated");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
|
|||
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
|
||||
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
|
||||
avetaPending: toNumber(readField(data, "avetaPending")),
|
||||
unassigned: toNumber(readField(data, "unassigned")),
|
||||
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
|
||||
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export interface DashboardStats {
|
|||
scheduledTomorrow: number;
|
||||
pendingUplifts: number;
|
||||
avetaPending: number;
|
||||
/** Open WOs with no dispatcher in the selected period. */
|
||||
unassigned: number;
|
||||
breakdown: DashboardBreakdownRow[];
|
||||
statusDistribution: DashboardStatusBucketRow[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import {
|
||||
addDaysIso,
|
||||
businessTodayIso,
|
||||
|
|
@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch(
|
|||
return search;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Unassigned tile's list: open WOs with no dispatcher in the
|
||||
* Dashboard period, the same set the backend counts. All time sends no dates,
|
||||
* so the board searches every week, undated WOs included.
|
||||
*/
|
||||
export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
|
||||
const search = workOrderRangeDrilldownSearch(range);
|
||||
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
|
||||
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
||||
return search;
|
||||
}
|
||||
|
||||
export function scheduledTomorrowDrilldownSearch(
|
||||
today: string = businessTodayIso(),
|
||||
): URLSearchParams {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
|
|||
return "";
|
||||
}
|
||||
|
||||
/** Like readString, but an empty value becomes undefined. */
|
||||
function readOptionalString(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | undefined {
|
||||
return readString(record, ...keys) || undefined;
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
|
|
@ -123,6 +131,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem {
|
|||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name"),
|
||||
clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"),
|
||||
sitePhone: readString(item, "phone", "Phone"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
|
|
@ -154,21 +164,30 @@ export function mapLocation(raw: unknown): Location {
|
|||
|
||||
const contacts = mapLocationContacts(item.contacts ?? item.Contacts);
|
||||
const mainContact = contacts[0];
|
||||
const legacyPhone = readString(item, "phone", "Phone") || undefined;
|
||||
const legacyContact =
|
||||
readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined;
|
||||
const legacyPhone = readOptionalString(item, "phone", "Phone");
|
||||
const legacyContact = readOptionalString(
|
||||
item,
|
||||
"contact",
|
||||
"Contact",
|
||||
"contactPerson",
|
||||
"ContactPerson",
|
||||
);
|
||||
|
||||
return {
|
||||
id: readId(item) || undefined,
|
||||
name: readString(item, "name", "Name") || undefined,
|
||||
address: address || undefined,
|
||||
city: readString(item, "city", "City") || undefined,
|
||||
state: readString(item, "state", "State") || undefined,
|
||||
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
|
||||
name: readOptionalString(item, "name", "Name"),
|
||||
title: readOptionalString(item, "title", "Title"),
|
||||
clientName: readOptionalString(item, "clientName", "ClientName", "accountName", "AccountName"),
|
||||
sitePhone: legacyPhone,
|
||||
notes: readOptionalString(item, "notes", "Notes"),
|
||||
address,
|
||||
city: readOptionalString(item, "city", "City"),
|
||||
state: readOptionalString(item, "state", "State"),
|
||||
zipCode: readOptionalString(item, "zipCode", "ZipCode", "Zip"),
|
||||
phone: mainContact ? mainContact.phone : legacyPhone,
|
||||
contact: mainContact ? mainContact.name : legacyContact,
|
||||
contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined,
|
||||
status: readString(item, "status", "Status") || undefined,
|
||||
contactEmail: readOptionalString(item, "contactEmail", "ContactEmail"),
|
||||
status: readOptionalString(item, "status", "Status"),
|
||||
accountId: readOptionalId(item) || undefined,
|
||||
contacts: contacts.length > 0 ? contacts : undefined,
|
||||
};
|
||||
|
|
@ -196,6 +215,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] {
|
|||
.filter((contact) => contact.name !== "" || contact.phone !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`,
|
||||
* `title` and `notes` are sent only when present so an update keeps the stored values.
|
||||
*/
|
||||
export function mapLocationToBackend(location: unknown): Record<string, unknown> {
|
||||
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
|
||||
string,
|
||||
|
|
@ -206,19 +229,25 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
|||
const mainContact = contacts[0];
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: item.name ?? "",
|
||||
name: typeof item.name === "string" ? item.name.trim() : "",
|
||||
address: item.address ?? "",
|
||||
city: item.city ?? "",
|
||||
state: item.state ?? "",
|
||||
zipCode: item.zipCode ?? "",
|
||||
phone: mainContact ? mainContact.phone : (item.phone ?? ""),
|
||||
contact: mainContact ? mainContact.name : (item.contact ?? ""),
|
||||
contactEmail: item.contactEmail ?? "",
|
||||
status: item.status ?? "Active",
|
||||
phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "",
|
||||
// A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name.
|
||||
contact: mainContact ? mainContact.name : readString(item, "contact").trim(),
|
||||
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
|
||||
accountId: item.accountId ?? "",
|
||||
contacts,
|
||||
};
|
||||
|
||||
for (const key of ["status", "title", "notes"] as const) {
|
||||
if (typeof item[key] === "string") {
|
||||
payload[key] = item[key];
|
||||
}
|
||||
}
|
||||
|
||||
const id = item.id ?? item.Id;
|
||||
if (typeof id === "string" || typeof id === "number") {
|
||||
payload.id = id;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,43 @@
|
|||
import { z } from "zod";
|
||||
import { US_STATE_CODES } from "@/data/areas";
|
||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
|
||||
export const locationContactSchema = z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Contact person is required")
|
||||
.max(100, "Contact person cannot exceed 100 characters"),
|
||||
phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"),
|
||||
.min(1, "Contact name is required")
|
||||
.max(100, "Contact name cannot exceed 100 characters"),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Contact phone is required")
|
||||
.max(20, "Contact phone cannot exceed 20 characters"),
|
||||
});
|
||||
|
||||
/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */
|
||||
export const locationSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
accountId: z.string().optional(),
|
||||
address: z.string().min(1, "Address is required"),
|
||||
city: z.string().min(1, "City is required"),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Site Code is required")
|
||||
.max(50, "Site Code cannot exceed 50 characters"),
|
||||
accountId: z.string().min(1, "Client is required"),
|
||||
address: z.string().trim().min(1, "Street Address is required"),
|
||||
city: z.string().trim().min(1, "City is required"),
|
||||
state: z
|
||||
.union([z.enum(US_STATE_CODES), z.literal("")])
|
||||
.refine((state): boolean => state !== "", { message: "State is required" }),
|
||||
zipCode: z.string().min(1, "Zip code is required"),
|
||||
sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"),
|
||||
contactEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === "" || z.email().safeParse(value).success, {
|
||||
message: "Invalid email address",
|
||||
}),
|
||||
contacts: z.array(locationContactSchema).min(1, "At least one contact is required"),
|
||||
contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"),
|
||||
status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }),
|
||||
zipCode: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||
|
|
@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = {
|
|||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zipCode: "",
|
||||
contacts: [{ name: "", phone: "" }],
|
||||
sitePhone: "",
|
||||
contactEmail: "",
|
||||
status: "Active",
|
||||
contacts: [{ name: "", phone: "" }],
|
||||
zipCode: "",
|
||||
title: "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ export interface LocationContact {
|
|||
export interface LocationListItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
/** Client (account) display name. */
|
||||
clientName: string;
|
||||
/** Site Phone, independent of the contacts. */
|
||||
sitePhone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
|
|
@ -26,6 +30,10 @@ export interface LocationListItem {
|
|||
export interface Location {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
clientName?: string;
|
||||
sitePhone?: string;
|
||||
notes?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
|
|
@ -61,5 +69,3 @@ export interface LocationsListResult {
|
|||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useCreateLocation(): UseMutationResult<Location, Error, LocationFormValues> {
|
||||
const navigate = useNavigate();
|
||||
/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */
|
||||
export function useCreateLocation(): UseMutationResult<void, Error, LocationFormValues> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: LocationFormValues) => locationsApi.create(values),
|
||||
mutationFn: async (values: LocationFormValues) => {
|
||||
await locationsApi.create({ ...values, status: "Active" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
toast.success("Location created successfully!");
|
||||
navigate("/locations");
|
||||
toast.success("Site added");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create location");
|
||||
if (isDuplicateSiteCodeError(error)) {
|
||||
return;
|
||||
}
|
||||
toast.error(error.message || "Failed to add site");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult<void, Error, string | num
|
|||
mutationFn: (id: string | number) => locationsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
toast.success("Location deleted successfully!");
|
||||
toast.success("Site deleted");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete location");
|
||||
toast.error(error.message || "Failed to delete site");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateLocationInput {
|
||||
|
|
@ -11,20 +9,25 @@ interface UpdateLocationInput {
|
|||
values: LocationFormValues;
|
||||
}
|
||||
|
||||
export function useUpdateLocation(): UseMutationResult<Location, Error, UpdateLocationInput> {
|
||||
const navigate = useNavigate();
|
||||
/** Saves a site. The response carries no site, so the list and detail are refetched. */
|
||||
export function useUpdateLocation(): UseMutationResult<void, Error, UpdateLocationInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) });
|
||||
toast.success("Location updated successfully!");
|
||||
navigate("/locations");
|
||||
mutationFn: async ({ id, values }: UpdateLocationInput) => {
|
||||
await locationsApi.update(id, values);
|
||||
},
|
||||
onSuccess: async (_data, variables) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.locations.detail(String(variables.id)),
|
||||
}),
|
||||
]);
|
||||
toast.success("Site updated");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update location");
|
||||
toast.error(error.message || "Failed to update site");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
14
src/domain/locations/utils/site-code-conflict.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { isHTTPError } from "ky";
|
||||
|
||||
export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists.";
|
||||
|
||||
/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */
|
||||
export function isDuplicateSiteCodeError(error: unknown): boolean {
|
||||
if (!isHTTPError(error)) {
|
||||
return false;
|
||||
}
|
||||
const data = error.data;
|
||||
const code =
|
||||
data && typeof data === "object" ? (data as Record<string, unknown>).code : undefined;
|
||||
return error.response.status === 409 || code === "DuplicateSiteCode";
|
||||
}
|
||||
23
src/domain/locations/utils/site-maps.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
|
||||
type SiteAddress = Pick<Location, "address" | "city" | "state" | "zipCode">;
|
||||
|
||||
/** Google Maps search link for a site address; opens in a new tab. */
|
||||
export function buildSiteMapsUrl(location: SiteAddress): string {
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
/** True once Street, City and State are all filled, so a map preview can render. */
|
||||
export function hasCompleteSiteAddress(location: SiteAddress): boolean {
|
||||
return [location.address, location.city, location.state].every(
|
||||
(part) => (part?.trim() ?? "") !== "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Key-less Google Maps embed URL for the read-only preview. */
|
||||
export function buildSiteMapEmbedUrl(location: SiteAddress): string {
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import { apiGet, apiPostNoContent } from "@/api/api";
|
||||
import {
|
||||
notificationFeedSchema,
|
||||
type NotificationFeed,
|
||||
|
|
@ -7,4 +7,7 @@ import {
|
|||
export const notificationsApi = {
|
||||
feed: async (): Promise<NotificationFeed> =>
|
||||
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
||||
/** Records who acknowledged the missed response deadline in the work order's audit history. */
|
||||
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
|
||||
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export type NotificationSession = {
|
|||
readIds: ReadonlySet<string>;
|
||||
dismiss: (ids: string[]) => void;
|
||||
markRead: (ids: string[]) => void;
|
||||
/** At-risk work orders hidden from the SLA banner for this session. */
|
||||
slaBannerDismissedIds: ReadonlySet<number>;
|
||||
dismissSlaBanner: (workOrderIds: number[]) => void;
|
||||
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
|
||||
claimSlaToasts: (workOrderIds: number[]) => number[];
|
||||
};
|
||||
|
||||
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { NotificationSessionContext } from "./notification-session-context";
|
||||
|
||||
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
|
||||
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
|
||||
if (ids.every((id) => current.has(id))) {
|
||||
return current;
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
|
|||
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
|
||||
const toastedSlaIds = useRef(new Set<number>());
|
||||
|
||||
const dismiss = useCallback((ids: string[]) => {
|
||||
setDismissedIds((current) => addAll(current, ids));
|
||||
|
|
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
|
|||
const markRead = useCallback((ids: string[]) => {
|
||||
setReadIds((current) => addAll(current, ids));
|
||||
}, []);
|
||||
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
|
||||
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
|
||||
}, []);
|
||||
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
|
||||
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
|
||||
claimed.forEach((id) => toastedSlaIds.current.add(id));
|
||||
return claimed;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ dismissedIds, readIds, dismiss, markRead }),
|
||||
[dismissedIds, readIds, dismiss, markRead],
|
||||
() => ({
|
||||
dismissedIds,
|
||||
readIds,
|
||||
dismiss,
|
||||
markRead,
|
||||
slaBannerDismissedIds,
|
||||
dismissSlaBanner,
|
||||
claimSlaToasts,
|
||||
}),
|
||||
[
|
||||
dismissedIds,
|
||||
readIds,
|
||||
dismiss,
|
||||
markRead,
|
||||
slaBannerDismissedIds,
|
||||
dismissSlaBanner,
|
||||
claimSlaToasts,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -50,9 +50,21 @@ const sectionSchema = z.object({
|
|||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
/** A work order past half of its SEV response window, with the server-computed clock. */
|
||||
const slaAtRiskSchema = z.object({
|
||||
id: z.number(),
|
||||
number: z.string().nullish(),
|
||||
severity: z.number(),
|
||||
startedAt: z.string(),
|
||||
deadlineAt: z.string(),
|
||||
percentElapsed: z.number(),
|
||||
});
|
||||
|
||||
export const notificationFeedSchema = z.object({
|
||||
generatedAt: z.string(),
|
||||
sections: z.array(sectionSchema),
|
||||
// Defaulted so a feed from a server without SLA alerts still parses.
|
||||
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
||||
});
|
||||
|
||||
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
||||
|
|
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
|
|||
export type NotificationItem = z.infer<typeof itemSchema>;
|
||||
export type NotificationSection = z.infer<typeof sectionSchema>;
|
||||
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
||||
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
|
||||
import type {
|
||||
NotificationFeed,
|
||||
NotificationItem,
|
||||
} from "@/domain/notifications/types/notification-feed";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function isBreachRowFor(workOrderId: number) {
|
||||
return (item: NotificationItem) =>
|
||||
item.reason === "sla" &&
|
||||
item.rowType === "acknowledge" &&
|
||||
item.target.workOrderId === workOrderId;
|
||||
}
|
||||
|
||||
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
|
||||
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
|
||||
const matches = isBreachRowFor(workOrderId);
|
||||
return {
|
||||
...feed,
|
||||
sections: feed.sections.map((section) => {
|
||||
const removed = section.items.filter(matches);
|
||||
if (removed.length === 0) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.filter((item) => !matches(item)),
|
||||
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAcknowledgeSlaBreach() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
|
||||
onSuccess: (_result, workOrderId) => {
|
||||
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
|
||||
feed ? withoutSlaBreach(feed, workOrderId) : feed,
|
||||
);
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
|
||||
});
|
||||
}
|
||||
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||
|
||||
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||
return workOrder.number != null && workOrder.number !== ""
|
||||
? `WO #${workOrder.number}`
|
||||
: `WO ${workOrder.id}`;
|
||||
}
|
||||
|
||||
/** One line whatever the count, as the top-of-app banner shows it. */
|
||||
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
|
||||
if (workOrders.length === 1) {
|
||||
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
|
||||
}
|
||||
return `${workOrders.length} work orders are at risk of missing their response deadline`;
|
||||
}
|
||||
|
||||
/** The one-time toast when a work order is first seen past half of its response window. */
|
||||
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
|
||||
}
|
||||
|
|
@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
|||
Reactive: 6,
|
||||
} as const;
|
||||
|
||||
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
||||
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
|
||||
// client never confirmed, so it lists the PM catalog.
|
||||
const TYPE_TO_API: Record<WOType, number> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||
Overdue: WORK_ORDER_TYPE_API.PM,
|
||||
};
|
||||
|
||||
function toApiInput(input: ServiceInput) {
|
||||
|
|
@ -51,7 +54,6 @@ export const servicesApi = {
|
|||
},
|
||||
|
||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||
if (workOrderType === "Overdue") return [];
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
|
|||
return useQuery({
|
||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||
enabled: effectiveType !== "Overdue",
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,129 @@
|
|||
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
export function validateVendorDocument(file: File): string | undefined {
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
|
||||
return "Only PDF, JPG, and PNG files are allowed.";
|
||||
/**
|
||||
* Client-confirmed media contract on the vendor portal: photos up to
|
||||
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
|
||||
* PDF documents keep the previous 10 MB document cap.
|
||||
*/
|
||||
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_PHOTO_BYTES = 10_000_000;
|
||||
const MAX_VIDEO_BYTES = 100_000_000;
|
||||
const MAX_VIDEO_DURATION_SECONDS = 90;
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.";
|
||||
|
||||
/** File-picker filter for every vendor portal upload, matching the allowlist below. */
|
||||
export const VENDOR_UPLOAD_ACCEPT =
|
||||
".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime";
|
||||
|
||||
type VendorUploadKind = "document" | "photo" | "video";
|
||||
|
||||
// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
|
||||
// extension decides (mobile browsers often send an empty or octet-stream type).
|
||||
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
|
||||
"application/pdf": "document",
|
||||
"image/jpeg": "photo",
|
||||
"image/jpg": "photo",
|
||||
"image/png": "photo",
|
||||
"image/heic": "photo",
|
||||
"video/mp4": "video",
|
||||
"video/quicktime": "video",
|
||||
};
|
||||
|
||||
const KIND_BY_EXTENSION: Record<string, VendorUploadKind> = {
|
||||
pdf: "document",
|
||||
jpg: "photo",
|
||||
jpeg: "photo",
|
||||
png: "photo",
|
||||
heic: "photo",
|
||||
mp4: "video",
|
||||
mov: "video",
|
||||
};
|
||||
|
||||
function resolveKind(file: File): VendorUploadKind | null {
|
||||
const name = file.name.toLowerCase();
|
||||
const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
|
||||
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
|
||||
}
|
||||
|
||||
/** Photos/videos already counted against the work order for one upload, with the limits. */
|
||||
export type VendorMediaUsage = {
|
||||
photos: number;
|
||||
videos: number;
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The count basis the server uses for this upload. A completion upload replaces a
|
||||
* document: the latest one by default (already excluded in `completion*`), or the
|
||||
* one the vendor picked, which is not counted when it is the same kind. Evidence
|
||||
* uploads count everything. Undefined when the backend does not report counts.
|
||||
*/
|
||||
export function vendorMediaUsage(
|
||||
counts: VendorPortalMediaCounts | null | undefined,
|
||||
purpose: "completion" | "evidence",
|
||||
replacedContentType?: string,
|
||||
): VendorMediaUsage | undefined {
|
||||
if (!counts) return undefined;
|
||||
const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
|
||||
if (purpose === "evidence") {
|
||||
return { ...limits, photos: counts.photos, videos: counts.videos };
|
||||
}
|
||||
if (replacedContentType === undefined) {
|
||||
return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
|
||||
}
|
||||
const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
|
||||
return {
|
||||
...limits,
|
||||
photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
|
||||
videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
|
||||
if (kind === "photo" && usage.photos >= usage.maxPhotos) {
|
||||
return `A work order can have at most ${usage.maxPhotos} photos.`;
|
||||
}
|
||||
if (kind === "video" && usage.videos >= usage.maxVideos) {
|
||||
return `A work order can have at most ${usage.maxVideos} videos.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function validateVendorDocument(
|
||||
file: File,
|
||||
usage?: VendorMediaUsage,
|
||||
): Promise<string | undefined> {
|
||||
const kind = resolveKind(file);
|
||||
if (!kind) {
|
||||
return UNSUPPORTED_TYPE_MESSAGE;
|
||||
}
|
||||
|
||||
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||
const countMessage = usage && countRejection(kind, usage);
|
||||
if (countMessage) {
|
||||
return countMessage;
|
||||
}
|
||||
|
||||
if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
|
||||
return "Photos must be 10 MB or smaller.";
|
||||
}
|
||||
|
||||
if (kind === "video") {
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
return "Videos must be 100 MB or smaller.";
|
||||
}
|
||||
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
|
||||
const duration = await probeVideoDurationSeconds(file);
|
||||
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
||||
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) {
|
||||
return "Files must be 10 MB or smaller.";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function validateUpliftAmount(
|
|||
return "Please include a reason for the uplift request.";
|
||||
}
|
||||
if (!options.file && !options.hasExisting) {
|
||||
return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
|
||||
return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
|
|||
refusedAt?: string;
|
||||
}
|
||||
|
||||
/** Counts for a new completion version exclude the document it replaces. */
|
||||
export interface VendorPortalMediaCounts {
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
photos: number;
|
||||
videos: number;
|
||||
completionPhotos: number;
|
||||
completionVideos: number;
|
||||
}
|
||||
|
||||
export interface VendorPortalDocument {
|
||||
id: number;
|
||||
originalFileName: string;
|
||||
|
|
@ -66,6 +76,8 @@ export interface VendorPortalUpliftRequest {
|
|||
requestedNTE: number;
|
||||
currentNTE?: number;
|
||||
vendorReason?: string;
|
||||
/** False when the request was raised from the work order; the vendor cannot revise or withdraw it. */
|
||||
raisedByVendor?: boolean;
|
||||
requestedAt?: string;
|
||||
decidedAt?: string;
|
||||
decidedByName?: string;
|
||||
|
|
@ -137,4 +149,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
|||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
signoffs: VendorPortalSignoff[];
|
||||
documents: VendorPortalDocument[];
|
||||
/** Per-work-order photo/video usage; absent until the backend reports it. */
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ import {
|
|||
type WorkOrdersListParams,
|
||||
type WorkOrdersListResult,
|
||||
} from "@/domain/work-orders/types/work-order-list";
|
||||
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||
import {
|
||||
ADVANCED_SEARCH_ALL_WEEKS_FROM,
|
||||
ADVANCED_SEARCH_ALL_WEEKS_TO,
|
||||
buildBoardSearchParamsRecord,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
|
|
@ -223,8 +227,13 @@ export const workOrdersApi = {
|
|||
"Unable to verify the work order number: search result set exceeded the safety page limit.",
|
||||
);
|
||||
}
|
||||
// Every week plus undated rows: a WO# must be unique regardless of schedule.
|
||||
const result = await workOrdersApi.searchBoard({
|
||||
search: normalized,
|
||||
datePreset: "Custom",
|
||||
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
|
||||
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
|
||||
includeDateless: true,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
|
|||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
if (draft.severity != null) {
|
||||
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function cryptoRandomId(): string {
|
|||
|
||||
function classifyFileName(fileName: string, url: string): FileCategory {
|
||||
const name = `${fileName} ${url}`.toLowerCase();
|
||||
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
|||
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
|
||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||
Overdue: WORK_ORDER_TYPE_API.Overdue,
|
||||
};
|
||||
|
||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export type DateRangeKey =
|
|||
export type CompletionDocFilter = "No" | "Yes" | "NN";
|
||||
|
||||
export interface WorkOrderAdvancedFilters {
|
||||
dateRange: DateRangeKey;
|
||||
/** `null` = no date range selected: search every week. */
|
||||
dateRange: DateRangeKey | null;
|
||||
customFrom: string;
|
||||
customTo: string;
|
||||
/** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */
|
||||
|
|
@ -34,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
|
|||
dispatchers: Set<string>;
|
||||
regions: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
||||
pastDue: boolean;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
|
|
@ -45,10 +48,12 @@ export interface WorkOrderAdvancedFilters {
|
|||
internalOnly: boolean;
|
||||
hasUplift: boolean;
|
||||
upliftStatuses: Set<UpliftFilterStatus>;
|
||||
/** Exact work orders to show; when non-empty the server ignores every other filter. */
|
||||
workOrderIds: Set<number>;
|
||||
}
|
||||
|
||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||
dateRange: "this-week",
|
||||
dateRange: null,
|
||||
customFrom: "",
|
||||
customTo: "",
|
||||
unscheduledOnly: false,
|
||||
|
|
@ -58,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
dispatchers: new Set(),
|
||||
regions: new Set(),
|
||||
statuses: new Set(),
|
||||
pastDue: false,
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
|
|
@ -69,6 +75,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
internalOnly: false,
|
||||
hasUplift: false,
|
||||
upliftStatuses: new Set(),
|
||||
workOrderIds: new Set(),
|
||||
};
|
||||
|
||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
||||
|
|
|
|||