Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions apps/csm-portal/webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,16 +261,7 @@ export default function App(): JSX.Element {
path="security-center/vulnerabilities/:id"
element={<ProductVulnerabilityDetailPage />}
/>
<Route
path="time-cards"
element={
<CsmComingSoonPage
title="Time cards"
description="Engineer time-tracking entries against cases, with approval flow."
blockedOn="csm-portal/backend time-cards endpoints"
/>
}
/>
<Route path="time-cards" element={<CsmTimeCardsPage />} />
</Route>

<Route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
{ key: "raise_git_issue", label: "Raise internal Git issue…", icon: <GitBranch size={16} />, disabled: true },
{ key: "create_task", label: "Create task…", icon: <ListChecks size={16} />, divider: true, disabled: true },
{ key: "request_call", label: "Request a call…", icon: <Phone size={16} />, disabled: true },
{ key: "log_time", label: "Log time…", icon: <Clock size={16} />, divider: true, disabled: true },
{ key: "log_time", label: "Log time…", icon: <Clock size={16} />, divider: true },
{ key: "copy_link", label: "Copy case link", icon: <Copy size={16} /> },
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: <Clock size={16} />, disabled: true },
{ id: "attachments", label: "Attachments", icon: <Paperclip size={16} /> },
// Time tracking is parked until its backend flow lands; disabled for now.
{ id: "time", label: "Time tracking", icon: <Layers size={16} />, disabled: true },
{ id: "time", label: "Time tracking", icon: <Layers size={16} /> },
{ id: "call-requests", label: "Call requests", icon: <Phone size={16} /> },
];

Expand Down Expand Up @@ -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<CaseAttachment | null>(
null,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1238,9 +1251,10 @@ export default function CsmCaseDetailPage(): JSX.Element {

{activeTab === "time" && (
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: "1fr" }}>
<TimeLogsWidget
logs={c.timeLogs}
onAdd={() => onAction({ secondary: "log_time" })}
<CaseTimeCardsPanel
caseId={c.id}
onLogTime={() => setLogTimeOpen(true)}
readOnly={isClosed}
/>
</Box>
)}
Expand All @@ -1261,6 +1275,31 @@ export default function CsmCaseDetailPage(): JSX.Element {
/>
)}

{logTimeOpen && (
<LogTimeCardDialog
caseId={c.id}
caseNumber={c.caseNumber ?? c.id}
projectId={c.projectId}
projectName={c.projectName}
isSubmitting={postTimeCard.isPending}
onClose={() => 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."),
})
}
/>
)}

<Dialog
open={!!pendingDelete}
onClose={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CsmTimeCard | null>(null);

const cards = useMemo(() => data ?? [], [data]);
const total = useMemo(
() => Math.round(cards.reduce((s, c) => s + c.totalHours, 0) * 100) / 100,
[cards],
);

return (
<Card variant="outlined" sx={{ p: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
mb: 1.25,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75 }}>
<Clock size={16} />
<Typography variant="subtitle2">Time tracked</Typography>
</Box>
{!readOnly && (
<Button
size="small"
variant="text"
startIcon={<Plus size={14} />}
onClick={onLogTime}
>
Log time
</Button>
)}
</Box>

{readOnly && (
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: "block" }}>
This case is closed — time tracking is read-only.
</Typography>
)}

<Box
sx={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
mb: 1,
}}
>
<Typography variant="h6" sx={{ lineHeight: 1 }}>
{total.toFixed(2)}h
</Typography>
<Typography variant="caption" color="text.secondary">
Across {cards.length} {cards.length === 1 ? "entry" : "entries"}
</Typography>
</Box>

<Divider sx={{ mb: 1 }} />

{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={22} />
</Box>
) : isError ? (
<Typography variant="body2" color="error" sx={{ py: 2 }}>
Could not load time cards.
</Typography>
) : cards.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
No time logged on this case yet.
</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{cards.map((c) => (
<Box
key={c.id}
sx={{
display: "flex",
flexDirection: "column",
gap: 0.5,
p: 1,
borderRadius: 1,
border: 1,
borderColor: "divider",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
}}
>
<Typography variant="body2">{c.userName}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="body2">{c.totalHours.toFixed(2)}h</Typography>
<TimeCardStatusChip state={c.state} />
{isTeamLead && !readOnly && c.state === "pending" && (
<Button
size="small"
variant="outlined"
onClick={() => setReviewCard(c)}
>
Review
</Button>
)}
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
{billableLabel(c.billable)} · {breakdownSummary(c.breakdown)} ·{" "}
<RelativeTime iso={c.submittedAt} />
</Typography>
{c.leadComment && (
<Typography variant="caption" color="text.secondary">
Lead: {c.leadComment}
</Typography>
)}
</Box>
))}
</Box>
)}

{reviewCard && (
<TimeCardReviewDialog
card={reviewCard}
isDeciding={decide.isPending}
onClose={() => setReviewCard(null)}
onDecide={(decision) =>
decide.mutate(decision, { onSuccess: () => setReviewCard(null) })
}
/>
)}
</Card>
);
}
Loading