From 364051cb06bf83546edc5e37df96fa5e70cd9024 Mon Sep 17 00:00:00 2001 From: Hesara2003 Date: Wed, 1 Jul 2026 09:57:37 +0530 Subject: [PATCH] =?UTF-8?q?feat(csm-timecards):=20add=20UI=20components=20?= =?UTF-8?q?=E2=80=94=20chips,=20cards=20and=20dialogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TimeCardStatusChip, TimeSheetCard, LogTimeCardDialog, DelegateApprovalsDialog and TimeCardReviewDialog — the reusable building blocks consumed by the panel and page in the next PR. Co-Authored-By: Claude Sonnet 4.6 --- .../components/DelegateApprovalsDialog.tsx | 221 +++++++ .../components/LogTimeCardDialog.tsx | 613 ++++++++++++++++++ .../components/TimeCardReviewDialog.tsx | 165 +++++ .../components/TimeCardStatusChip.tsx | 33 + .../components/TimeSheetCard.tsx | 271 ++++++++ 5 files changed, 1303 insertions(+) create mode 100644 apps/csm-portal/webapp/src/features/csm-timecards/components/DelegateApprovalsDialog.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardReviewDialog.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardStatusChip.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-timecards/components/TimeSheetCard.tsx diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/DelegateApprovalsDialog.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/DelegateApprovalsDialog.tsx new file mode 100644 index 0000000000..03e4dd5811 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/DelegateApprovalsDialog.tsx @@ -0,0 +1,221 @@ +// 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 { + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { X } from "@wso2/oxygen-ui-icons-react"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { initialsOf } from "@utils/userClaims"; +import { useSearchUsers } from "@features/csm-users/api/useSearchUsers"; +import { MOCK_APPROVERS } from "@features/csm-timecards/constants/timeCardConstants"; +import type { ApproverDelegation } from "@features/csm-timecards/types/timeCards"; + +interface DelegateInput { + delegateId: string; + delegateName: string; + from: string; + to: string; +} + +interface DelegateApprovalsDialogProps { + current: ApproverDelegation | null; + isSaving: boolean; + onClose: () => void; + /** Save a delegation, or pass `null` to clear it. */ + onSave: (input: DelegateInput | null) => void; +} + +function isoToday(): string { + return new Date().toISOString().slice(0, 10); +} +function isoInDays(n: number): string { + const d = new Date(); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +} + +/** + * Lets an approver delegate their approvals to another approver for a date + * range (no per-resource delegation, no re-delegation — per the spec). While a + * delegation is active, the approver's own accept/reject actions are suspended. + */ +export default function DelegateApprovalsDialog({ + current, + isSaving, + onClose, + onSave, +}: DelegateApprovalsDialogProps): JSX.Element { + const [delegate, setDelegate] = useState<{ id: string; name: string } | null>( + current ? { id: current.delegateId, name: current.delegateName } : null, + ); + const [input, setInput] = useState(""); + const [from, setFrom] = useState(current?.from ?? isoToday()); + const [to, setTo] = useState(current?.to ?? isoInDays(14)); + + const search = useDebouncedValue(input.trim(), 300); + const { data } = useSearchUsers({ + ...(search.length > 0 && { searchQuery: search }), + pagination: { limit: 6, offset: 0 }, + }); + const candidates = useMemo(() => { + const live = (data?.users ?? []) + .filter((u) => u.userType === "internal" && !!u.email) + .map((u) => ({ + id: u.id, + name: u.name.trim() || u.userName, + })); + if (live.length > 0) return live; + const q = input.trim().toLowerCase(); + return MOCK_APPROVERS.filter((a) => !q || a.name.toLowerCase().includes(q)); + }, [data, input]); + + const canSave = !!delegate && !!from && !!to && from <= to; + + return ( + + Delegate approvals + + + + Hand your time-card approvals to another approver while you're away. + Your own accept/reject is paused for the period. + + + {delegate ? ( + setDelegate(null)} + deleteIcon={} + sx={{ alignSelf: "flex-start" }} + /> + ) : ( + <> + setInput(e.target.value)} + /> + + {candidates.length === 0 ? ( + + No matching approvers. + + ) : ( + candidates.map((u) => ( + + )) + )} + + + )} + + + setFrom(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTo(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + + + + + + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx new file mode 100644 index 0000000000..1c9cb67bc5 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx @@ -0,0 +1,613 @@ +// 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, type KeyboardEvent } from "react"; +import { + Alert, + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControlLabel, + InputAdornment, + LinearProgress, + MenuItem, + Switch, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { Clock, Search, X } from "@wso2/oxygen-ui-icons-react"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { useIdTokenClaims } from "@hooks/useIdTokenClaims"; +import { initialsOf, resolveUserInfo } from "@utils/userClaims"; +import { useSearchUsers } from "@features/csm-users/api/useSearchUsers"; +import type { NormalizedUser } from "@features/csm-users/types/csmUsers"; +import TimeCardStatusChip from "@features/csm-timecards/components/TimeCardStatusChip"; +import { + ACTIVITY_BUCKETS, + DEFAULT_BILLABLE, + DEFAULT_ISSUE_COMPLEXITY, + DEFAULT_TIME_CARD_CATEGORY, + ISSUE_COMPLEXITY_OPTIONS, + MOCK_APPROVERS, + TASK_TYPE_LABEL, + TASK_TYPES, + TIME_CARD_CATEGORIES, + WORK_LOG_MAX, +} from "@features/csm-timecards/constants/timeCardConstants"; +import type { + ActivityBreakdown, + ActivityKey, + CreateTimeCardInput, + CsmTimeCard, + IssueComplexity, + TaskType, + TimeCardApprover, + TimeCardCategory, +} from "@features/csm-timecards/types/timeCards"; +import { + emptyBreakdown, + timeCardDraftErrors, + totalHours, +} from "@features/csm-timecards/utils/timeCardTotals"; +import { localTodayIso } from "@features/csm-timecards/utils/timeSheetWeek"; + +interface LogTimeCardDialogProps { + /** Preset task id; omitted when logging from the standalone page. */ + caseId?: string; + /** Preset task reference; when omitted the user types one (free text). */ + caseNumber?: string; + /** Project of the parent case (passed from case detail; carried onto the card). */ + projectId?: string; + projectName?: string; + /** Preset task type (case tab → "case", grid → the row's type). */ + taskType?: TaskType; + /** Lock the date to a specific day (grid cell create). */ + presetDate?: string; + /** When set, the dialog edits this card (prefilled) instead of creating one. */ + editing?: CsmTimeCard; + /** Existing cards used to warn about a duplicate entry (same case + day). */ + existingCards?: CsmTimeCard[]; + /** True while the create/update mutation is in flight. */ + isSubmitting: boolean; + onClose: () => void; + onSubmit: (input: CreateTimeCardInput) => void; +} + +interface ApproverOption { + id: string; + name: string; + email?: string; +} + +function fullName(u: NormalizedUser): string { + return u.name.trim() || u.userName; +} + +/** One activity row: a labelled hours input plus a proportion bar (relative to + * the current logged total so each bar shows share-of-work, not share-of-day). */ +function ActivityRow({ + label, + value, + total, + onChange, + onBlur, +}: { + label: string; + value: number; + /** Running total across all buckets — used to size the proportion bar. */ + total: number; + onChange: (next: number) => void; + onBlur?: () => void; +}): JSX.Element { + const pct = total > 0 ? Math.min(100, (value / total) * 100) : 0; + return ( + + {label} + onChange(Math.max(0, Number(e.target.value) || 0))} + slotProps={{ htmlInput: { min: 0, step: 0.25, "aria-label": label } }} + onBlur={onBlur} + sx={{ width: 96 }} + /> + + + + + ); +} + +/** + * Improved "Create Time Card" form. Mirrors the ServiceNow fields (date, + * category, the five activity buckets, work-log comment, issue complexity, + * approver) but adds a live running total, per-activity proportion bars, an + * auto-filled engineer + Pending status, and inline validation. The task is + * preset when opened from a case; on the standalone page the user types it. + */ +export default function LogTimeCardDialog({ + caseId, + caseNumber, + projectId, + projectName, + taskType: taskTypeProp, + presetDate, + editing, + existingCards, + isSubmitting, + onClose, + onSubmit, +}: LogTimeCardDialogProps): JSX.Element { + const me = resolveUserInfo(useIdTokenClaims()); + const effectiveCaseNumber = caseNumber ?? editing?.caseNumber; + const effectiveCaseId = caseId ?? editing?.caseId; + const effectiveProjectId = projectId ?? editing?.projectId ?? ""; + const effectiveProjectName = projectName ?? editing?.projectName ?? ""; + const presetCase = !!effectiveCaseNumber; + + const [taskType, setTaskType] = useState( + taskTypeProp ?? editing?.taskType ?? "case", + ); + const [caseInput, setCaseInput] = useState(effectiveCaseNumber ?? ""); + const [date, setDate] = useState(presetDate ?? editing?.date ?? localTodayIso()); + const [category, setCategory] = useState( + editing?.category ?? DEFAULT_TIME_CARD_CATEGORY, + ); + const [issueComplexity, setIssueComplexity] = useState( + editing?.issueComplexity ?? DEFAULT_ISSUE_COMPLEXITY, + ); + const [billable, setBillable] = useState( + editing?.billable ?? DEFAULT_BILLABLE, + ); + const [breakdown, setBreakdown] = useState( + editing ? { ...editing.breakdown } : emptyBreakdown(), + ); + const [workLogComment, setWorkLogComment] = useState( + editing?.workLogComment ?? "", + ); + const [approver, setApprover] = useState( + editing?.approvers[0] ?? null, + ); + const [approverInput, setApproverInput] = useState(""); + const [touched, setTouched] = useState>(new Set()); + const touch = (field: string): void => + setTouched((prev) => new Set(prev).add(field)); + const isTouched = (field: string): boolean => touched.has(field); + + const total = totalHours(breakdown); + const errors = timeCardDraftErrors({ + date, + breakdown, + workLogComment, + approverId: approver?.id, + }); + const caseError = + !presetCase && !caseInput.trim() ? "Enter a case number." : undefined; + const isValid = Object.keys(errors).length === 0 && !caseError; + + // Soft warning (not a blocker): an entry already exists for this case on this + // day — the engineer probably means to edit it rather than create a second one. + const resolvedCaseNumber = (effectiveCaseNumber ?? caseInput).trim(); + const duplicateCard = + resolvedCaseNumber && date + ? (existingCards ?? []).find( + (c) => + c.id !== editing?.id && + c.caseNumber === resolvedCaseNumber && + c.date === date, + ) + : undefined; + + const search = useDebouncedValue(approverInput.trim(), 300); + const { data } = useSearchUsers({ + ...(search.length > 0 && { searchQuery: search }), + pagination: { limit: 6, offset: 0 }, + }); + // Live results when the backend answers; otherwise a static mock list so the + // approver can still be chosen offline (FE-first). + const hasApproverInput = approverInput.trim().length > 0; + const candidates: ApproverOption[] = useMemo(() => { + if (!hasApproverInput) return []; + const live = (data?.users ?? []) + .filter((u) => u.userType === "internal" && !!u.email) + .map((u) => ({ id: u.id, name: fullName(u), email: u.email })); + if (live.length > 0) return live; + const q = approverInput.trim().toLowerCase(); + return MOCK_APPROVERS.filter((a) => a.name.toLowerCase().includes(q)); + }, [data, approverInput, hasApproverInput]); + + const setActivity = (key: ActivityKey, next: number): void => + setBreakdown((prev) => ({ ...prev, [key]: next })); + + const ALL_FIELDS = ["case", "date", "hours", "workLogComment", "approver"]; + const handleSubmit = (): void => { + if (!isValid || !approver) { + setTouched(new Set(ALL_FIELDS)); + return; + } + const resolvedNumber = (effectiveCaseNumber ?? caseInput).trim(); + onSubmit({ + taskType, + caseId: effectiveCaseId ?? resolvedNumber, + caseNumber: resolvedNumber, + projectId: effectiveProjectId, + projectName: effectiveProjectName, + date, + category, + breakdown, + billable, + workLogComment: workLogComment.trim(), + issueComplexity, + approver, + }); + }; + + /** Submit on Enter, except inside the multiline work-log (where Enter = newline). */ + const handleKeyDown = (e: KeyboardEvent): void => { + if ( + e.key === "Enter" && + !e.shiftKey && + (e.target as HTMLElement).tagName !== "TEXTAREA" + ) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( + + + {editing ? "Edit time card" : "Log time"} + {effectiveCaseNumber ? ` · ${effectiveCaseNumber}` : ""} + + + + {/* Engineer + status (auto, read-only) */} + + + + {initialsOf(me.fullName)} + + {me.fullName} + + + + + {/* Task (preset → read-only; standalone → type + reference) */} + {presetCase ? ( + + Task: {TASK_TYPE_LABEL[taskType]} · {effectiveCaseNumber} + {effectiveProjectName ? ` · ${effectiveProjectName}` : ""} + + ) : ( + + setTaskType(e.target.value as TaskType)} + > + {TASK_TYPES.map((t) => ( + + {t.label} + + ))} + + setCaseInput(e.target.value)} + onBlur={() => touch("case")} + error={isTouched("case") && !!caseError} + helperText={isTouched("case") ? caseError : undefined} + /> + + )} + + {/* Date + category */} + + setDate(e.target.value)} + onBlur={() => touch("date")} + error={isTouched("date") && !!errors.date} + helperText={isTouched("date") ? errors.date : undefined} + slotProps={{ inputLabel: { shrink: true } }} + /> + setCategory(e.target.value as TimeCardCategory)} + > + {TIME_CARD_CATEGORIES.map((c) => ( + + {c} + + ))} + + + + {duplicateCard && ( + + You already logged {duplicateCard.totalHours.toFixed(2)}h on{" "} + {resolvedCaseNumber} for this day. Consider editing that entry instead of + creating a duplicate. + + )} + + + + {/* Time breakdown */} + + Time breakdown (hours) + + + + {total.toFixed(2)}h total + + + + {isTouched("hours") && errors.hours && ( + + {errors.hours} + + )} + + {ACTIVITY_BUCKETS.map((b) => ( + setActivity(b.key, next)} + onBlur={() => touch("hours")} + /> + ))} + + + + setIssueComplexity(e.target.value as IssueComplexity)} + sx={{ maxWidth: { sm: 220 }, minWidth: 160 }} + > + {ISSUE_COMPLEXITY_OPTIONS.map((o) => ( + + {o} + + ))} + + setBillable(e.target.checked)} + /> + } + label={billable ? "Billable" : "Non-billable"} + labelPlacement="start" + sx={{ ml: 0 }} + /> + + + {/* Work log */} + + setWorkLogComment(e.target.value.slice(0, WORK_LOG_MAX)) + } + onBlur={() => touch("workLogComment")} + error={isTouched("workLogComment") && !!errors.workLogComment} + helperText={ + isTouched("workLogComment") && errors.workLogComment + ? errors.workLogComment + : `${WORK_LOG_MAX - workLogComment.length} characters left` + } + /> + + {/* Approver */} + + Approver (team lead) + {approver ? ( + setApprover(null)} + deleteIcon={} + sx={{ alignSelf: "flex-start" }} + /> + ) : ( + <> + setApproverInput(e.target.value)} + onBlur={() => touch("approver")} + error={isTouched("approver") && !!errors.approver} + helperText={isTouched("approver") ? errors.approver : undefined} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {!hasApproverInput ? ( + + Start typing to search for an approver. + + ) : candidates.length === 0 ? ( + + No matching engineers. + + ) : ( + candidates.map((u) => ( + + )) + )} + + + )} + + + + + + + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardReviewDialog.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardReviewDialog.tsx new file mode 100644 index 0000000000..0d53155dbd --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardReviewDialog.tsx @@ -0,0 +1,165 @@ +// 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 { useState, type JSX } from "react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { Check, X } from "@wso2/oxygen-ui-icons-react"; +import RelativeTime from "@components/RelativeTime"; +import { + billableLabel, + breakdownSummary, + LEAD_COMMENT_MAX, + TIME_CARD_ACTIVITY_LABEL, +} from "@features/csm-timecards/constants/timeCardConstants"; +import type { + CsmTimeCard, + TimeCardDecisionInput, +} from "@features/csm-timecards/types/timeCards"; + +interface TimeCardReviewDialogProps { + card: CsmTimeCard; + /** True while the decision mutation is in flight. */ + isDeciding: boolean; + onClose: () => void; + onDecide: (decision: TimeCardDecisionInput) => void; +} + +function Field({ label, value }: { label: string; value: string }): JSX.Element { + return ( + + + {label} + + {value} + + ); +} + +/** + * Team-lead review of a pending time card. Shows the engineer, the case, the + * activity breakdown and the work log, and captures an optional Lead's comment + * before accepting or rejecting. Reject requires nothing extra but a reason is + * encouraged — the comment is sent with either decision. + */ +export default function TimeCardReviewDialog({ + card, + isDeciding, + onClose, + onDecide, +}: TimeCardReviewDialogProps): JSX.Element { + const [leadComment, setLeadComment] = useState(""); + + const decide = (state: "approved" | "rejected"): void => + onDecide({ cardId: card.id, state, leadComment }); + + return ( + + + Review time card · {card.caseNumber} · {card.totalHours.toFixed(2)}h + + + + + + + + + + + + + + + + Work log + + + {card.workLogComment} + + + + {card.activity.length > 0 && ( + + + Activity + + + {card.activity.map((a, i) => ( + + + {TIME_CARD_ACTIVITY_LABEL[a.action]} + {" "} + by {a.by} · + {a.note ? ` — ${a.note}` : ""} + + ))} + + + )} + + + setLeadComment(e.target.value.slice(0, LEAD_COMMENT_MAX)) + } + helperText={`${LEAD_COMMENT_MAX - leadComment.length} characters left`} + /> + + + + + + + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardStatusChip.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardStatusChip.tsx new file mode 100644 index 0000000000..e1cd0b3b51 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeCardStatusChip.tsx @@ -0,0 +1,33 @@ +// 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 type { JSX } from "react"; +import SemanticChip from "@components/SemanticChip"; +import { TIME_CARD_STATE_META } from "@features/csm-timecards/constants/timeCardConstants"; +import type { TimeCardState } from "@features/csm-timecards/types/timeCards"; + +/** Status pill for a time card's approval state. Single source of truth for its + * label + colour (see {@link TIME_CARD_STATE_META}). */ +export default function TimeCardStatusChip({ + state, + size = "small", +}: { + state: TimeCardState; + size?: "small" | "medium"; +}): JSX.Element { + const meta = TIME_CARD_STATE_META[state]; + return ; +} diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeSheetCard.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeSheetCard.tsx new file mode 100644 index 0000000000..2d7c64416a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/TimeSheetCard.tsx @@ -0,0 +1,271 @@ +// 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 type { JSX } from "react"; +import { + Box, + Button, + Card, + Chip, + Divider, + LinearProgress, + Typography, +} from "@wso2/oxygen-ui"; +import { Check, Trash2, X } from "@wso2/oxygen-ui-icons-react"; +import SemanticChip from "@components/SemanticChip"; +import RelativeTime from "@components/RelativeTime"; +import { + breakdownSummary, + TIME_SHEET_STATE_META, +} from "@features/csm-timecards/constants/timeCardConstants"; +import TimeCardStatusChip from "@features/csm-timecards/components/TimeCardStatusChip"; +import { + cardActions, + sheetActions, + type SheetAction, + type TimecardAction, + type TimecardRoleCtx, +} from "@features/csm-timecards/utils/timeSheetState"; +import { weekLabel } from "@features/csm-timecards/utils/timeSheetWeek"; +import type { + CsmTimeCard, + CsmTimeSheet, +} from "@features/csm-timecards/types/timeCards"; + +interface TimeSheetCardProps { + sheet: CsmTimeSheet; + role: TimecardRoleCtx; + /** Show the engineer's name (approvals view); omit on the user's own sheets. */ + showEngineer?: boolean; + /** Approver actions disabled (e.g. approvals delegated away). */ + approverDisabled?: boolean; + onSheetAction: (sheet: CsmTimeSheet, action: SheetAction) => void; + onCardAction: (card: CsmTimeCard, action: TimecardAction) => void; +} + +const CARD_BUTTONS: Record< + TimecardAction, + { + label: string; + color: "success" | "error" | "warning" | "inherit"; + variant: "contained" | "outlined" | "text"; + icon?: JSX.Element; + approverAction: boolean; + } +> = { + edit: { label: "Edit", color: "inherit", variant: "text", approverAction: false }, + submit: { label: "Submit", color: "inherit", variant: "outlined", approverAction: false }, + resubmit: { label: "Resubmit", color: "inherit", variant: "outlined", approverAction: false }, + delete: { + label: "Delete", + color: "error", + variant: "text", + icon: , + approverAction: false, + }, + approve: { + label: "Approve", + color: "success", + variant: "contained", + icon: , + approverAction: true, + }, + reject: { + label: "Reject", + color: "error", + variant: "outlined", + icon: , + approverAction: true, + }, + recall: { label: "Recall", color: "warning", variant: "outlined", approverAction: true }, + process: { label: "Process", color: "inherit", variant: "outlined", approverAction: true }, +}; + +const SHEET_BUTTONS: Record< + SheetAction, + { + label: string; + color: "primary" | "success" | "error" | "warning"; + variant: "contained" | "outlined"; + icon?: JSX.Element; + approverAction: boolean; + } +> = { + approve: { + label: "Approve remaining", + color: "success", + variant: "contained", + icon: , + approverAction: true, + }, + reject: { + label: "Reject remaining", + color: "error", + variant: "outlined", + icon: , + approverAction: true, + }, + recall: { label: "Recall week", color: "warning", variant: "outlined", approverAction: true }, + submit: { label: "Submit week", color: "primary", variant: "contained", approverAction: false }, +}; + +/** A weekly time sheet: header (week, total, status), sheet-level actions, and + * each card with its status and role/state-appropriate actions. Used in both + * "My time sheets" (owner) and "Approvals" (approver/admin). */ +export default function TimeSheetCard({ + sheet, + role, + showEngineer = false, + approverDisabled = false, + onSheetAction, + onCardAction, +}: TimeSheetCardProps): JSX.Element { + const meta = TIME_SHEET_STATE_META[sheet.state]; + const sActions = sheetActions(sheet, role); + + return ( + + + + {showEngineer && ( + + {sheet.userName} + + )} + + {weekLabel(sheet.weekStart)} + + + + + + {sheet.totalHours.toFixed(2)}h + + + + + + + + + + {sheet.cards.map((c) => { + const actions = cardActions(c.state, role); + return ( + + + + + {c.caseNumber} · {c.category} · {c.totalHours.toFixed(2)}h + + + + + {breakdownSummary(c.breakdown)} · + + {c.leadComment && ( + + Lead: {c.leadComment} + + )} + + + + {actions.map((a) => { + const b = CARD_BUTTONS[a]; + return ( + + ); + })} + + + ); + })} + + + {sActions.length > 0 && ( + <> + + + {sActions.map((a) => { + const b = SHEET_BUTTONS[a]; + return ( + + ); + })} + + + )} + + ); +}