diff --git a/apps/csm-portal/webapp/src/App.tsx b/apps/csm-portal/webapp/src/App.tsx index e491de4e80..523cf37b22 100644 --- a/apps/csm-portal/webapp/src/App.tsx +++ b/apps/csm-portal/webapp/src/App.tsx @@ -95,6 +95,9 @@ const ProductVulnerabilityDetailPage = lazy( const CsmEngagementsPage = lazy( () => import("@features/csm-engagements/pages/CsmEngagementsPage"), ); +const CsmTimeCardsPage = lazy( + () => import("@features/csm-timecards/pages/CsmTimeCardsPage"), +); /** * Landing for `/`. Defers to AuthGuard's post-login deep-link restore when a @@ -258,16 +261,7 @@ export default function App(): JSX.Element { path="security-center/vulnerabilities/:id" element={} /> - - } - /> + } /> , disabled: true }, { key: "create_task", label: "Create task…", icon: , divider: true, disabled: true }, { key: "request_call", label: "Request a call…", icon: , disabled: true }, - { key: "log_time", label: "Log time…", icon: , divider: true, disabled: true }, + { key: "log_time", label: "Log time…", icon: , divider: true }, { key: "copy_link", label: "Copy case link", icon: }, ); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index 70c2f763ec..f6b6fc5931 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -77,10 +77,12 @@ import { LinkedItemsWidget, ProductContextWidget, SlaTimelineWidget, - TimeLogsWidget, WatchersWidget, } from "@features/csm-cases/components/CaseDetailWidgets"; import { CallRequestsWidget } from "@features/csm-cases/components/CallRequestsWidget"; +import CaseTimeCardsPanel from "@features/csm-timecards/components/CaseTimeCardsPanel"; +import LogTimeCardDialog from "@features/csm-timecards/components/LogTimeCardDialog"; +import { usePostTimeCard } from "@features/csm-timecards/api/useTimeCards"; import { caseIdLabel } from "@features/csm-cases/utils/caseIdentity"; import { publicCommentGateReason, @@ -237,8 +239,7 @@ const TAB_DEFS: Array<{ // SLA tab is parked until its backend clock data is wired; disabled for now. { id: "sla", label: "SLA", icon: , disabled: true }, { id: "attachments", label: "Attachments", icon: }, - // Time tracking is parked until its backend flow lands; disabled for now. - { id: "time", label: "Time tracking", icon: , disabled: true }, + { id: "time", label: "Time tracking", icon: }, { id: "call-requests", label: "Call requests", icon: }, ]; @@ -285,6 +286,8 @@ export default function CsmCaseDetailPage(): JSX.Element { const [metaCollapsed, setMetaCollapsed] = useState(false); const [composerOpen, setComposerOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); + const [logTimeOpen, setLogTimeOpen] = useState(false); + const postTimeCard = usePostTimeCard(); // Attachment pending delete confirmation (drives the confirm dialog). const [pendingDelete, setPendingDelete] = useState( null, @@ -577,6 +580,16 @@ export default function CsmCaseDetailPage(): JSX.Element { return; } + if (action.secondary === "log_time") { + // CAMG-006: closed cases are read-only — no new time entries. + if (data?.state === "closed") { + showError("This case is closed — time tracking is read-only."); + return; + } + setLogTimeOpen(true); + return; + } + setFeedback({ message: SECONDARY_TOAST[action.secondary] ?? `Action: ${action.secondary}`, severity: "info", @@ -1238,9 +1251,10 @@ export default function CsmCaseDetailPage(): JSX.Element { {activeTab === "time" && ( - onAction({ secondary: "log_time" })} + setLogTimeOpen(true)} + readOnly={isClosed} /> )} @@ -1261,6 +1275,31 @@ export default function CsmCaseDetailPage(): JSX.Element { /> )} + {logTimeOpen && ( + setLogTimeOpen(false)} + onSubmit={(input) => + postTimeCard.mutate(input, { + onSuccess: () => { + setLogTimeOpen(false); + setActiveTab("time"); + setFeedback({ + message: "Time card submitted for review.", + severity: "success", + sticky: false, + }); + }, + onError: () => showError("Could not log time."), + }) + } + /> + )} + { diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/CaseTimeCardsPanel.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/CaseTimeCardsPanel.tsx new file mode 100644 index 0000000000..b42e75c0d3 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/CaseTimeCardsPanel.tsx @@ -0,0 +1,198 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useMemo, useState, type JSX } from "react"; +import { + Box, + Button, + Card, + CircularProgress, + Divider, + Typography, +} from "@wso2/oxygen-ui"; +import { Clock, Plus } from "@wso2/oxygen-ui-icons-react"; +import RelativeTime from "@components/RelativeTime"; +import { useCaseTimeCards, useDecideTimeCard } from "@features/csm-timecards/api/useTimeCards"; +import { useIsTeamLead } from "@features/csm-timecards/hooks/useIsTeamLead"; +import { + billableLabel, + breakdownSummary, +} from "@features/csm-timecards/constants/timeCardConstants"; +import TimeCardStatusChip from "@features/csm-timecards/components/TimeCardStatusChip"; +import TimeCardReviewDialog from "@features/csm-timecards/components/TimeCardReviewDialog"; +import type { CsmTimeCard } from "@features/csm-timecards/types/timeCards"; + +interface CaseTimeCardsPanelProps { + caseId: string; + /** Opens the log-time dialog (owned by the page so the action bar can trigger it). */ + onLogTime: () => void; + /** + * When true the case is closed (CAMG-006): time tracking is read-only, so + * logging and inline review are disabled. Existing entries stay visible. + */ + readOnly?: boolean; +} + +/** + * The body of a case's "Time tracking" tab: the time cards logged on this case, + * with a running total and per-entry status. A team lead can review (accept or + * reject) any pending entry inline — unless the case is closed, in which case + * the panel is read-only. + */ +export default function CaseTimeCardsPanel({ + caseId, + onLogTime, + readOnly = false, +}: CaseTimeCardsPanelProps): JSX.Element { + const { data, isLoading, isError } = useCaseTimeCards(caseId); + const isTeamLead = useIsTeamLead(); + const decide = useDecideTimeCard(); + const [reviewCard, setReviewCard] = useState(null); + + const cards = useMemo(() => data ?? [], [data]); + const total = useMemo( + () => Math.round(cards.reduce((s, c) => s + c.totalHours, 0) * 100) / 100, + [cards], + ); + + return ( + + + + + Time tracked + + {!readOnly && ( + + )} + + + {readOnly && ( + + This case is closed — time tracking is read-only. + + )} + + + + {total.toFixed(2)}h + + + Across {cards.length} {cards.length === 1 ? "entry" : "entries"} + + + + + + {isLoading ? ( + + + + ) : isError ? ( + + Could not load time cards. + + ) : cards.length === 0 ? ( + + No time logged on this case yet. + + ) : ( + + {cards.map((c) => ( + + + {c.userName} + + {c.totalHours.toFixed(2)}h + + {isTeamLead && !readOnly && c.state === "pending" && ( + + )} + + + + {billableLabel(c.billable)} · {breakdownSummary(c.breakdown)} ·{" "} + + + {c.leadComment && ( + + Lead: {c.leadComment} + + )} + + ))} + + )} + + {reviewCard && ( + setReviewCard(null)} + onDecide={(decision) => + decide.mutate(decision, { onSuccess: () => setReviewCard(null) }) + } + /> + )} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/pages/CsmTimeCardsPage.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/pages/CsmTimeCardsPage.tsx new file mode 100644 index 0000000000..1e486e9681 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/pages/CsmTimeCardsPage.tsx @@ -0,0 +1,767 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useMemo, useState, type JSX } from "react"; +import { + Alert, + AlertTitle, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + InputAdornment, + MenuItem, + Tab, + Tabs, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { ListFilter, Search, X } from "@wso2/oxygen-ui-icons-react"; +import { + useApprovalQueue, + useApproveSheet, + useDecideCard, + useDeleteCard, + useDelegation, + useMyTimeSheets, + useProcessCard, + useRecallCard, + useRecallSheet, + useRejectSheet, + useSetDelegation, + useSubmitSheet, + useUpdateCard, +} from "@features/csm-timecards/api/useTimeSheets"; +import { useProjectOptions } from "@features/csm-cases/api/useProjectOptions"; +import type { BeProject } from "@api/backend/types"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { useSearchUsers } from "@features/csm-users/api/useSearchUsers"; +import { timeCardNotices } from "@features/csm-timecards/utils/timeCardNotifications"; +import { weekStartOf } from "@features/csm-timecards/utils/timeSheetWeek"; +import { + breakdownSummary, + TIME_CARD_STATE_META, +} from "@features/csm-timecards/constants/timeCardConstants"; +import { useTimecardRole } from "@features/csm-timecards/hooks/useTimecardRole"; +import TimeSheetCard from "@features/csm-timecards/components/TimeSheetCard"; +import TimeCardReviewDialog from "@features/csm-timecards/components/TimeCardReviewDialog"; +import DelegateApprovalsDialog from "@features/csm-timecards/components/DelegateApprovalsDialog"; +import LogTimeCardDialog from "@features/csm-timecards/components/LogTimeCardDialog"; +import { + type SheetAction, + type TimecardAction, +} from "@features/csm-timecards/utils/timeSheetState"; +import type { + CsmTimeCard, + CsmTimeSheet, + TimeCardSearchFilters, + TimeCardState, +} from "@features/csm-timecards/types/timeCards"; + +type TabId = "mine" | "approvals"; + +/** + * Time cards workspace. Three tabs: **My time sheets** (weekly grouping, submit, + * edit/resubmit), **Approvals** (approver/admin: approve/reject/recall, with + * delegation), and **Reports** (KPIs + exceptions). Logging a new card opens the + * log dialog; logging from a case lands the card in that week's sheet. + */ +export default function CsmTimeCardsPage(): JSX.Element { + const role = useTimecardRole(); + const [tab, setTab] = useState("mine"); + const activeTab: TabId = tab !== "mine" && !role.isApprover ? "mine" : tab; + + const [editingCard, setEditingCard] = useState(null); + const [reviewCard, setReviewCard] = useState(null); + const [delegateOpen, setDelegateOpen] = useState(false); + const [deletingCard, setDeletingCard] = useState(null); + + // Search filters (sent as a POST body, never query params). Project, work + // item and state scope both "My time sheets" and "Approvals"; Approvals adds + // an engineer filter. + const [filterProject, setFilterProject] = useState(""); + const [filterWorkItem, setFilterWorkItem] = useState(""); + const [filterState, setFilterState] = useState(""); + const [filterEngineer, setFilterEngineer] = useState(""); + + const baseFilters: TimeCardSearchFilters = { + ...(filterProject && { projectIds: [filterProject] }), + ...(filterWorkItem.trim() && { workItemId: filterWorkItem.trim() }), + ...(filterState && { states: [filterState] }), + }; + + const mySheets = useMyTimeSheets(baseFilters); + const queue = useApprovalQueue(role.isApprover, baseFilters); + const delegation = useDelegation(); + const projects = useProjectOptions(); + + const update = useUpdateCard(); + const submitSheet = useSubmitSheet(); + const approveSheet = useApproveSheet(); + const rejectSheet = useRejectSheet(); + const recallSheet = useRecallSheet(); + const decideCard = useDecideCard(); + const recallCard = useRecallCard(); + const processCard = useProcessCard(); + const deleteCard = useDeleteCard(); + const setDelegation = useSetDelegation(); + + const activeDelegation = delegation.data ?? null; + const approverDisabled = !!activeDelegation; + + const anyFilterActive = + !!filterProject || !!filterWorkItem.trim() || !!filterState || !!filterEngineer.trim(); + const clearFilters = (): void => { + setFilterProject(""); + setFilterWorkItem(""); + setFilterState(""); + setFilterEngineer(""); + }; + + const handleSheetAction = (sheet: CsmTimeSheet, action: SheetAction): void => { + const target = { userId: sheet.userId, weekStart: sheet.weekStart }; + if (action === "submit") submitSheet.mutate(target); + else if (action === "approve") approveSheet.mutate(target); + else if (action === "reject") rejectSheet.mutate(target); + else if (action === "recall") recallSheet.mutate(target); + }; + + const handleCardAction = (card: CsmTimeCard, action: TimecardAction): void => { + if (action === "edit") setEditingCard(card); + else if (action === "delete") setDeletingCard(card); + else if (action === "submit" || action === "resubmit") + submitSheet.mutate({ userId: card.userId, weekStart: weekStartOf(card.date) }); + else if (action === "approve" || action === "reject") setReviewCard(card); + else if (action === "recall") recallCard.mutate(card.id); + else if (action === "process") processCard.mutate(card.id); + }; + + const [dismissedNoticeIds, setDismissedNoticeIds] = useState>(new Set()); + const allNotices = timeCardNotices(mySheets.data ?? []); + const notices = allNotices.filter((n) => !dismissedNoticeIds.has(n.cardId)); + + /** Flat list of the signed-in user's cards — used for duplicate-entry warnings. */ + const myCards = (mySheets.data ?? []) + .flatMap((s) => s.cards) + .sort((a, b) => b.date.localeCompare(a.date)); + + return ( + + + Time cards + + Review your weekly time sheets and (for approvers) submissions. Log + time from a case's Time tracking tab. + + + + setTab(v as TabId)} + sx={{ borderBottom: 1, borderColor: "divider" }} + > + + {role.isApprover && } + + + {/* My time sheets */} + {activeTab === "mine" && + (mySheets.isLoading ? ( + + + + ) : mySheets.isError ? ( + Could not load your time sheets. + ) : ( + + {notices.length > 0 && ( + + setDismissedNoticeIds( + new Set([...dismissedNoticeIds, ...notices.map((n) => n.cardId)]), + ) + } + > + + {notices.length === 1 + ? "A time card needs your attention" + : `${notices.length} time cards need your attention`} + + + {notices.slice(0, 4).map((n) => ( +
  • {n.message}
  • + ))} +
    +
    + )} + + + } + /> + + {(() => { + const q = filterEngineer.trim().toLowerCase(); + const filtered = (mySheets.data ?? []).filter( + (s) => !q || s.userName.toLowerCase().includes(q), + ); + if (filtered.length === 0) { + return ( + + ); + } + return filtered.map((s) => ( + + )); + })()} +
    + ))} + + {/* Approvals */} + {activeTab === "approvals" && role.isApprover && ( + + + Submitted for your approval + + + + setFilterEngineer(e.target.value)} + sx={{ width: 180 }} + /> + } + /> + + {activeDelegation && ( + + Approvals delegated to {activeDelegation.delegateName}{" "} + ({activeDelegation.from} → {activeDelegation.to}). Your accept/reject + is paused for this period. + + )} + + {queue.isLoading ? ( + + + + ) : queue.isError ? ( + Could not load the approval queue. + ) : (queue.data ?? []).length === 0 ? ( + + ) : ( + (() => { + const q = filterEngineer.trim().toLowerCase(); + const filtered = (queue.data ?? []).filter( + (s) => !q || s.userName.toLowerCase().includes(q), + ); + if (filtered.length === 0) { + return ; + } + return filtered.map((s) => ( + + )); + })() + )} + + )} + + {/* Dialogs. Logging new cards happens from a case; here we only edit an + existing (rejected/recalled) card, review, delegate, or delete. */} + {editingCard && ( + setEditingCard(null)} + onSubmit={(input) => + update.mutate( + { + cardId: editingCard.id, + patch: { + date: input.date, + category: input.category, + billable: input.billable, + breakdown: input.breakdown, + workLogComment: input.workLogComment, + issueComplexity: input.issueComplexity, + }, + }, + { onSuccess: () => setEditingCard(null) }, + ) + } + /> + )} + + {reviewCard && ( + setReviewCard(null)} + onDecide={(decision) => + decideCard.mutate(decision, { onSuccess: () => setReviewCard(null) }) + } + /> + )} + + {delegateOpen && ( + setDelegateOpen(false)} + onSave={(input) => + setDelegation.mutate(input, { onSuccess: () => setDelegateOpen(false) }) + } + /> + )} + + {deletingCard && ( + setDeletingCard(null)} maxWidth="xs" fullWidth> + Delete time card? + + + This permanently removes the entry below. This can't be undone. + + + + {deletingCard.caseNumber} · {deletingCard.date} + + + {deletingCard.totalHours.toFixed(2)}h · {deletingCard.category} + + + {breakdownSummary(deletingCard.breakdown)} + + + + + + + + + )} +
    + ); +} + +const FILTER_STATES: TimeCardState[] = [ + "pending", + "submitted", + "approved", + "rejected", + "recalled", + "processed", +]; + +/** Searchable engineer picker used in both filter bars. */ +function EngineerFilter({ + value, + onChange, +}: { + value: string; + onChange: (name: string) => void; +}): JSX.Element { + const [input, setInput] = useState(""); + const search = useDebouncedValue(input.trim(), 300); + const { data } = useSearchUsers({ + ...(search.length > 0 && { searchQuery: search }), + pagination: { limit: 6, offset: 0 }, + }); + + const candidates = useMemo( + () => + input.trim().length === 0 + ? [] + : (data?.users ?? []) + .filter((u) => u.userType === "internal") + .map((u) => ({ + id: u.id, + name: u.name.trim() || u.userName, + email: u.email, + })), + [data, input], + ); + + if (value) { + return ( + onChange("")} + deleteIcon={} + /> + ); + } + + return ( + + setInput(e.target.value)} + sx={{ width: 180 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + {input.trim().length > 0 && ( + + {candidates.length === 0 ? ( + + No matching engineers. + + ) : ( + candidates.map((u) => ( + + )) + )} + + )} + + ); +} + +/** Shared filter bar for the My time sheets and Approvals tabs. */ +function FilterBar({ + projects, + filterProject, + setFilterProject, + filterWorkItem, + setFilterWorkItem, + filterState, + setFilterState, + onClear, + engineerSlot, +}: { + projects: BeProject[]; + filterProject: string; + setFilterProject: (v: string) => void; + filterWorkItem: string; + setFilterWorkItem: (v: string) => void; + filterState: TimeCardState | ""; + setFilterState: (v: TimeCardState | "") => void; + onClear: () => void; + engineerSlot?: JSX.Element; +}): JSX.Element { + const activeChips: { key: string; label: string; onDelete: () => void }[] = []; + if (filterProject) { + const name = projects.find((p) => p.id === filterProject)?.name ?? filterProject; + activeChips.push({ + key: "project", + label: `Project: ${name}`, + onDelete: () => setFilterProject(""), + }); + } + if (filterWorkItem.trim()) { + activeChips.push({ + key: "workItem", + label: `Work item: ${filterWorkItem.trim()}`, + onDelete: () => setFilterWorkItem(""), + }); + } + if (filterState) { + activeChips.push({ + key: "state", + label: `State: ${TIME_CARD_STATE_META[filterState].label}`, + onDelete: () => setFilterState(""), + }); + } + + return ( + + {/* Controls row */} + + + + + Filters + + + + setFilterProject(e.target.value)} + sx={{ width: 200 }} + > + All projects + {projects.map((p) => ( + + {p.name ?? p.id} + + ))} + + + setFilterWorkItem(e.target.value)} + sx={{ width: 190 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {engineerSlot} + + setFilterState(e.target.value as TimeCardState | "")} + sx={{ width: 150 }} + > + All states + {FILTER_STATES.map((s) => ( + + {TIME_CARD_STATE_META[s].label} + + ))} + + + + + + {/* Active filter chips */} + {activeChips.length > 0 && ( + + + Active: + + {activeChips.map((c) => ( + } + sx={{ height: 22, fontSize: "0.72rem" }} + /> + ))} + + + )} + + ); +} + +function Centered({ children }: { children: JSX.Element }): JSX.Element { + return ( + {children} + ); +} + +function Empty({ text }: { text: string }): JSX.Element { + return ( + + {text} + + ); +}