mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 23:43:13 +00:00
Merge pull request #39 from Sea-Haven-Industries/feature/wo-board-shell
feat(work-orders): add weekly board page shell and list chrome
This commit is contained in:
commit
45f4272cf7
13 changed files with 1025 additions and 16 deletions
20
src/app/(protected)/vendors/index.tsx
vendored
20
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -535,19 +535,21 @@ export default function VendorsListPage() {
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{impactError && !isLoadingImpact && (
|
||||
{Boolean(impactError) && !isLoadingImpact && (
|
||||
<Alert severity="warning">
|
||||
Could not verify linked work orders. Close and try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && (
|
||||
<Alert severity="error">
|
||||
This vendor cannot be deactivated because it still has open work orders.
|
||||
</Alert>
|
||||
)}
|
||||
{deactivationImpact != null &&
|
||||
!isLoadingImpact &&
|
||||
!deactivationImpact.canDeactivate && (
|
||||
<Alert severity="error">
|
||||
This vendor cannot be deactivated because it still has open work orders.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && (
|
||||
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Open work orders ({deactivationImpact.openWorkOrders.length})
|
||||
|
|
@ -571,7 +573,7 @@ export default function VendorsListPage() {
|
|||
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||
</Typography>
|
||||
{wo.status && (
|
||||
{Boolean(wo.status) && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{wo.status}
|
||||
</Typography>
|
||||
|
|
@ -583,7 +585,7 @@ export default function VendorsListPage() {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{deleteError && <Alert severity="error">{deleteError}</Alert>}
|
||||
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
|
|
|||
|
|
@ -226,12 +226,12 @@ export default function WorkOrderViewPage() {
|
|||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||
return;
|
||||
}
|
||||
|
||||
commentMutation.mutate(
|
||||
{
|
||||
text: commentText.trim(),
|
||||
commentType: activeTab === "customer" ? "customer" : "internal",
|
||||
},
|
||||
{ text: commentText.trim(), commentType: activeTab },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
|
@ -680,7 +680,7 @@ export default function WorkOrderViewPage() {
|
|||
<Typography variant="body2" color="text.secondary">
|
||||
{vendorEditor?.vendorName}
|
||||
</Typography>
|
||||
{vendorEditorError && <Alert severity="error">{vendorEditorError}</Alert>}
|
||||
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
|
||||
{vendorEditorLoading ? (
|
||||
<Box className="flex justify-center p-6">
|
||||
<CircularProgress size={28} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { useMemo } from "react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { ChevronDown, UserX, Users } from "lucide-react";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
MOCK_DISPATCHER_USERS,
|
||||
getDispatcherAvatarColor,
|
||||
} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
||||
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
|
||||
type DispatcherFilterProps = {
|
||||
selectedIds: Set<string>;
|
||||
onChange: (ids: Set<string>) => void;
|
||||
users: WorkOrderUser[];
|
||||
currentUserId?: string | number;
|
||||
};
|
||||
|
||||
function userInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||
}
|
||||
|
||||
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||
style={{ background: `${color}25`, color }}
|
||||
>
|
||||
{userInitials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DispatcherFilter({
|
||||
selectedIds,
|
||||
onChange,
|
||||
users,
|
||||
currentUserId,
|
||||
}: DispatcherFilterProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
||||
const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
|
||||
const allSelected = selectedIds.size === 0;
|
||||
const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId);
|
||||
const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (allSelected) return "All dispatchers";
|
||||
if (meOnly) return "My WOs";
|
||||
if (unassignedOnly) return "Unassigned";
|
||||
return `${selectedIds.size} selected`;
|
||||
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
|
||||
|
||||
const dispatchers = useMemo(() => {
|
||||
const byId = new Map<string, WorkOrderUser>();
|
||||
for (const user of [...MOCK_DISPATCHER_USERS, ...users]) {
|
||||
byId.set(String(user.id), user);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [users]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
||||
style={
|
||||
!allSelected
|
||||
? {
|
||||
height: 38,
|
||||
padding: "0 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: "var(--color-primary-tint)",
|
||||
borderColor: "#bfe2f5",
|
||||
color: "var(--primary)",
|
||||
}
|
||||
: {
|
||||
height: 38,
|
||||
padding: "0 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: "var(--card)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
|
||||
{label}
|
||||
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||
</MenuAnchorButton>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CheckboxMenuItem
|
||||
checked={allSelected}
|
||||
label="All dispatchers"
|
||||
onClick={() => onChange(new Set())}
|
||||
/>
|
||||
|
||||
{meId != null && (
|
||||
<CheckboxMenuItem
|
||||
checked={meOnly}
|
||||
label="My WOs only"
|
||||
labelClassName="font-medium text-primary"
|
||||
onClick={() => onChange(new Set([meId]))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CheckboxMenuItem
|
||||
checked={selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED)}
|
||||
label="Unassigned"
|
||||
left={<UserX size={14} className="shrink-0 text-muted-foreground" />}
|
||||
className="mb-1 pb-2"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)}
|
||||
/>
|
||||
|
||||
{dispatchers.map((user) => {
|
||||
const id = String(user.id);
|
||||
const isMe = meId === id;
|
||||
return (
|
||||
<CheckboxMenuItem
|
||||
key={id}
|
||||
checked={selectedIds.has(id)}
|
||||
label={user.name}
|
||||
left={<DispatcherAvatar name={user.name} color={getDispatcherAvatarColor(user)} />}
|
||||
right={
|
||||
isMe ? <span className="text-[10px] text-muted-foreground">me</span> : undefined
|
||||
}
|
||||
onClick={() => toggle(id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
|
||||
|
||||
import { NavPillButton } from "@/components/ui/nav-pill-button";
|
||||
|
||||
import {
|
||||
buildWeekWindow,
|
||||
isFutureWeek,
|
||||
isLiveWeek,
|
||||
} from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
type WeekNavProps = {
|
||||
weekMonday: string;
|
||||
|
||||
onChange: (mondayIso: string) => void;
|
||||
};
|
||||
|
||||
const VISIBLE_COUNT = 3;
|
||||
|
||||
const WINDOW_BEFORE = 12;
|
||||
|
||||
const WINDOW_AFTER = 1;
|
||||
|
||||
function WeekLiveBadge() {
|
||||
return (
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
|
||||
fontSize: 9,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: "#ffffff",
|
||||
|
||||
background: "var(--success)",
|
||||
|
||||
borderRadius: 9999,
|
||||
|
||||
padding: "1px 5px",
|
||||
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||
const weeks = useMemo(
|
||||
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
||||
|
||||
[weekMonday],
|
||||
);
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => weeks.findIndex((week) => week.mondayIso === weekMonday),
|
||||
|
||||
[weeks, weekMonday],
|
||||
);
|
||||
|
||||
const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
|
||||
const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
|
||||
|
||||
setOffset((current) => {
|
||||
if (activeIndex < current) return Math.min(activeIndex, maxOffset);
|
||||
|
||||
if (activeIndex > current + VISIBLE_COUNT - 1) {
|
||||
return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}, [activeIndex, weeks.length]);
|
||||
|
||||
const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
|
||||
|
||||
const canGoBack = offset > 0;
|
||||
|
||||
const canGoForward = offset + VISIBLE_COUNT < weeks.length;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<NavArrowButton
|
||||
disabled={!canGoBack}
|
||||
onClick={() => setOffset((current) => current - 1)}
|
||||
aria-label="Previous weeks"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</NavArrowButton>
|
||||
|
||||
{visible.map((week) => {
|
||||
const isActive = weekMonday === week.mondayIso;
|
||||
|
||||
const isDisabled = isFutureWeek(week.mondayIso);
|
||||
|
||||
const showLiveBadge = isLiveWeek(week.mondayIso);
|
||||
|
||||
return (
|
||||
<NavPillButton
|
||||
key={week.mondayIso}
|
||||
active={isActive}
|
||||
disabled={isDisabled}
|
||||
onClick={() => onChange(week.mondayIso)}
|
||||
>
|
||||
{week.label}
|
||||
|
||||
{showLiveBadge && <WeekLiveBadge />}
|
||||
</NavPillButton>
|
||||
);
|
||||
})}
|
||||
|
||||
<NavArrowButton
|
||||
disabled={!canGoForward}
|
||||
onClick={() => setOffset((current) => current + 1)}
|
||||
aria-label="Next weeks"
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</NavArrowButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control";
|
||||
|
||||
import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
|
||||
type WoTypeSegmentProps = {
|
||||
value: WorkOrderTypeFilter;
|
||||
|
||||
onChange: (value: WorkOrderTypeFilter) => void;
|
||||
};
|
||||
|
||||
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
|
||||
|
||||
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
|
||||
return (
|
||||
<SegmentGroup>
|
||||
{OPTIONS.map((option) => (
|
||||
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
|
||||
{option}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</SegmentGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
|
||||
import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav";
|
||||
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
|
||||
|
||||
type WorkOrdersFilterBarProps = {
|
||||
weekMonday: string;
|
||||
onWeekChange: (mondayIso: string) => void;
|
||||
dispatcherIds: Set<string>;
|
||||
onDispatcherChange: (ids: Set<string>) => void;
|
||||
typeFilter: WorkOrderTypeFilter;
|
||||
onTypeChange: (value: WorkOrderTypeFilter) => void;
|
||||
users: WorkOrderUser[];
|
||||
currentUserId?: string | number;
|
||||
filteredCount: number;
|
||||
scopeTotalCount: number;
|
||||
};
|
||||
|
||||
export function WorkOrdersFilterBar({
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
dispatcherIds,
|
||||
onDispatcherChange,
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
users,
|
||||
currentUserId,
|
||||
filteredCount,
|
||||
scopeTotalCount,
|
||||
}: WorkOrdersFilterBarProps) {
|
||||
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
|
||||
<div className="flex-1" />
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { AppliedFilterChip } from "@/components/ui/applied-filter-chip";
|
||||
import { AppliedFiltersBar } from "@/components/ui/applied-filters-bar";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
buildAdvancedFilterChips,
|
||||
countAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
|
||||
type WorkOrdersAdvancedFiltersBannerProps = {
|
||||
applied: WorkOrderAdvancedFilters;
|
||||
totalCount: number;
|
||||
sites: { id: string | number; name: string }[];
|
||||
users: WorkOrderUser[];
|
||||
onClearChip: (chipKey: string) => void;
|
||||
onClearAll: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersAdvancedFiltersBanner({
|
||||
applied,
|
||||
totalCount,
|
||||
sites,
|
||||
users,
|
||||
onClearChip,
|
||||
onClearAll,
|
||||
}: WorkOrdersAdvancedFiltersBannerProps) {
|
||||
if (countAdvancedFilters(applied) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const siteNameOf = (id: string) => sites.find((site) => String(site.id) === id)?.name ?? id;
|
||||
const dispatcherNameOf = (id: string) => users.find((user) => String(user.id) === id)?.name ?? id;
|
||||
|
||||
const chips = buildAdvancedFilterChips(applied, { siteNameOf, dispatcherNameOf });
|
||||
|
||||
return (
|
||||
<AppliedFiltersBar
|
||||
countLabel={`${totalCount} work order${totalCount !== 1 ? "s" : ""}`}
|
||||
onClearAll={onClearAll}
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<AppliedFilterChip
|
||||
key={chip.key}
|
||||
group={chip.group}
|
||||
value={chip.value}
|
||||
count={chip.count}
|
||||
accent={chip.accent}
|
||||
onDismiss={() => onClearChip(chip.key)}
|
||||
/>
|
||||
))}
|
||||
</AppliedFiltersBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
|
||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
|
||||
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
||||
|
||||
type WorkOrdersListHeaderProps = {
|
||||
search: string;
|
||||
|
||||
onSearchChange: (value: string) => void;
|
||||
|
||||
onOpenAdvancedFilters: () => void;
|
||||
|
||||
onNewWorkOrder: () => void;
|
||||
|
||||
activeFilterCount: number;
|
||||
|
||||
isFetching?: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrdersListHeader({
|
||||
search,
|
||||
|
||||
onSearchChange,
|
||||
|
||||
onOpenAdvancedFilters,
|
||||
|
||||
onNewWorkOrder,
|
||||
|
||||
activeFilterCount,
|
||||
|
||||
isFetching = false,
|
||||
}: WorkOrdersListHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search this week…"
|
||||
aria-label="Search work orders"
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
variant="outline"
|
||||
icon={<SlidersHorizontal size={13} />}
|
||||
onClick={onOpenAdvancedFilters}
|
||||
aria-label="Advanced Filters"
|
||||
title="Advanced Filters"
|
||||
badge={activeFilterCount}
|
||||
>
|
||||
Filters
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
|
||||
New WO
|
||||
</ToolbarButton>
|
||||
|
||||
{isFetching && <CircularProgress size={14} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import { Button, IconButton, Tooltip } from "@mui/material";
|
||||
import { ResourceListTable, type ResourceListColumn } from "@/components/ui/resource-list-table";
|
||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { SortDirection } from "@/domain/work-orders/types/work-order-list";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
function displayDateTime(value: string): string {
|
||||
return formatDateTime(value) || "—";
|
||||
}
|
||||
|
||||
type WorkOrdersTableProps = {
|
||||
rows: WorkOrderListItem[];
|
||||
isLoading: boolean;
|
||||
sort: { column: string; direction: SortDirection };
|
||||
onSort: (column: string) => void;
|
||||
onView: (id: string | number) => void;
|
||||
onEdit: (id: string | number) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersTable({
|
||||
rows,
|
||||
isLoading,
|
||||
sort,
|
||||
onSort,
|
||||
onView,
|
||||
onEdit,
|
||||
}: WorkOrdersTableProps) {
|
||||
const columns: ResourceListColumn<WorkOrderListItem>[] = [
|
||||
{
|
||||
id: "number",
|
||||
header: "WO #",
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => onView(row.id)}
|
||||
>
|
||||
{row.number || row.id}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "title",
|
||||
header: "Title",
|
||||
sortable: true,
|
||||
render: (row) => row.title,
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
header: "Location",
|
||||
render: (row) => row.location,
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: "Priority",
|
||||
render: (row) => row.priority,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
render: (row) => <WorkOrderStatusBadge status={row.status || "Open"} />,
|
||||
},
|
||||
{
|
||||
id: "createdDate",
|
||||
header: "Created",
|
||||
cellClassName: "whitespace-nowrap",
|
||||
render: (row) => displayDateTime(row.createdDate),
|
||||
},
|
||||
{
|
||||
id: "assignedTo",
|
||||
header: "Assigned To",
|
||||
render: (row) => row.assignedTo || "Unassigned",
|
||||
},
|
||||
{
|
||||
id: "lastUpdated",
|
||||
header: "Last Updated",
|
||||
cellClassName: "whitespace-nowrap",
|
||||
render: (row) => displayDateTime(row.lastUpdated),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<>
|
||||
<Tooltip title="View">
|
||||
<IconButton size="small" aria-label="View work order" onClick={() => onView(row.id)}>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" aria-label="Edit work order" onClick={() => onEdit(row.id)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResourceListTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No work orders found"
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { MediaUploader } from "./media-uploader";
|
||||
|
||||
type MediaUploadModalProps = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
};
|
||||
|
||||
export function MediaUploadModal({
|
||||
row,
|
||||
open,
|
||||
onOpenChange,
|
||||
uploads,
|
||||
onChange,
|
||||
}: MediaUploadModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
style: { borderRadius: 12, maxWidth: 520, width: "100%" },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
||||
Photos & Videos
|
||||
</DialogTitle>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
<MediaUploader uploads={uploads} onChange={onChange} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { useRef } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
|
||||
type MediaUploaderProps = {
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
};
|
||||
|
||||
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
|
||||
};
|
||||
|
||||
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
Drag files here or click to browse
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
{uploads.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{uploads.map((u) => {
|
||||
const TypeIcon = FILETYPE_ICON[u.fileType];
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
{u.fileType === "image" ? (
|
||||
<img
|
||||
src={u.url}
|
||||
alt={u.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : u.fileType === "video" ? (
|
||||
<>
|
||||
<video
|
||||
src={u.url}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(u.id)}
|
||||
title="Remove"
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<div
|
||||
className="truncate"
|
||||
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{u.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
||||
import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type {
|
||||
WorkOrderBarFilters,
|
||||
WorkOrderTypeFilter,
|
||||
} from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import {
|
||||
clearAdvancedFilterGroup,
|
||||
cloneAdvancedFilters,
|
||||
countAdvancedFilters,
|
||||
getInitialAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import {
|
||||
compactWeekLabel,
|
||||
currentWeekMonday,
|
||||
weekRange,
|
||||
} from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
function defaultDispatcherIds(currentUserId?: string | number): Set<string> {
|
||||
if (isWoTableMockEnabled()) {
|
||||
return new Set();
|
||||
}
|
||||
if (currentUserId != null && currentUserId !== "") {
|
||||
return new Set([String(currentUserId)]);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** Phase 2: map bar filters to API list params. */
|
||||
export function toListParamsFromBar(
|
||||
bar: WorkOrderBarFilters,
|
||||
): Pick<WorkOrdersListParams, "dateFrom" | "dateTo" | "type" | "assigneeIds"> {
|
||||
const [dateFrom, dateTo] = weekRange(bar.weekMonday);
|
||||
return {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
type: bar.type === "All" ? undefined : bar.type,
|
||||
assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [advOpen, setAdvOpen] = useState(false);
|
||||
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() =>
|
||||
defaultDispatcherIds(currentUserId),
|
||||
);
|
||||
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const barFilters = useMemo<WorkOrderBarFilters>(
|
||||
() => ({
|
||||
weekMonday,
|
||||
dispatcherIds,
|
||||
type: typeFilter,
|
||||
}),
|
||||
[weekMonday, dispatcherIds, typeFilter],
|
||||
);
|
||||
|
||||
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
||||
|
||||
const scopeListParams = useMemo<WorkOrdersListParams>(
|
||||
() => ({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
...toListParamsFromBar(barFilters),
|
||||
}),
|
||||
[barFilters],
|
||||
);
|
||||
|
||||
const listParams = useMemo<WorkOrdersListParams>(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: 1,
|
||||
pageSize: WORK_ORDERS_TABLE_PAGE_SIZE,
|
||||
sortBy,
|
||||
sortDir,
|
||||
...toListParamsFromBar(barFilters),
|
||||
}),
|
||||
[debouncedSearch, sortBy, sortDir, barFilters],
|
||||
);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() => (advApplied ? countAdvancedFilters(advApplied) : 0),
|
||||
[advApplied],
|
||||
);
|
||||
|
||||
const setWeekMonday = (value: string) => {
|
||||
setWeekMondayState(value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const setDispatcherIds = (value: Set<string>) => {
|
||||
setDispatcherIdsState(new Set(value));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const setTypeFilter = (value: WorkOrderTypeFilter) => {
|
||||
setTypeFilterState(value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
||||
setAdvApplied(cloneAdvancedFilters(filters));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearAdvancedFilters = () => {
|
||||
setAdvApplied(null);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearAdvancedFilterChip = (chipKey: string) => {
|
||||
if (!advApplied) return;
|
||||
const next = clearAdvancedFilterGroup(advApplied, chipKey);
|
||||
setAdvApplied(next);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
setSortDir((current) => {
|
||||
const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc";
|
||||
if (next === "") {
|
||||
setSortBy("");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSortBy(column);
|
||||
setSortDir("asc");
|
||||
};
|
||||
|
||||
const sortIndicator = (column: string) => {
|
||||
if (sortBy !== column) {
|
||||
return "";
|
||||
}
|
||||
return sortDir === "desc" ? " ↓" : " ↑";
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
advOpen,
|
||||
advApplied,
|
||||
advInitial: getInitialAdvancedFilters(advApplied),
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortDir,
|
||||
weekMonday,
|
||||
dispatcherIds,
|
||||
typeFilter,
|
||||
barFilters,
|
||||
weekLabel,
|
||||
listParams,
|
||||
scopeListParams,
|
||||
activeFilterCount,
|
||||
handleSearchChange,
|
||||
applyAdvancedFilters,
|
||||
clearAdvancedFilters,
|
||||
clearAdvancedFilterChip,
|
||||
handleSort,
|
||||
sortIndicator,
|
||||
setAdvOpen,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setWeekMonday,
|
||||
setDispatcherIds,
|
||||
setTypeFilter,
|
||||
setSortBy,
|
||||
setSortDir,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
useWorkOrderUsers,
|
||||
useWorkOrdersList,
|
||||
|
|
@ -292,7 +292,7 @@ export default function WorkOrdersListPage() {
|
|||
<TableCell className="whitespace-nowrap">
|
||||
{formatDateTime(row.scheduledOn)}
|
||||
</TableCell>
|
||||
<TableCell>{row.dispatcherName || "Unassigned"}</TableCell>
|
||||
<TableCell>{row.tech || "Unassigned"}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue