From 4be163e7d33ff17f4a7537e2daf784385dbf73e7 Mon Sep 17 00:00:00 2001 From: ilhom Date: Fri, 3 Jul 2026 20:13:47 +0700 Subject: [PATCH 01/23] refactor(frontend): introduce UserAvatar with real photo support Replace ad-hoc initials logic scattered across comments, nav-user, and profile-header with a single UserAvatar component that renders the user's actual profile photo (AvatarImage) when available and falls back to deterministic-color initials (hash mod 6 palette) when not. Supersedes the former UserInitials helper (removed). Co-Authored-By: Claude Sonnet 4.6 --- src/components/common/user-avatar.tsx | 83 +++++++++++++++++++ .../cost-request-comment/comment-item.tsx | 9 +- .../cost-request-comment/comments-panel.tsx | 39 ++------- src/components/nav/nav-user.tsx | 33 +++----- src/components/profile/profile-header.tsx | 21 ++--- 5 files changed, 117 insertions(+), 68 deletions(-) create mode 100644 src/components/common/user-avatar.tsx diff --git a/src/components/common/user-avatar.tsx b/src/components/common/user-avatar.tsx new file mode 100644 index 0000000..ef8a0fd --- /dev/null +++ b/src/components/common/user-avatar.tsx @@ -0,0 +1,83 @@ +"use client" + +// UserAvatar — shared avatar component. Renders the user's uploaded photo +// (mst_user.profile_picture_url, surfaced as `profilePictureUrl` on UserDetail) +// when available, falling back to initials. Consolidates three previously +// duplicated implementations: +// - cost-request-comment/comments-panel.tsx's `UserInitials` (deterministic +// per-userId color hash fallback — pass `colorHash` to reproduce that look) +// - nav/nav-user.tsx (plain shadcn fallback, already-resolved avatar/name) +// - profile/profile-header.tsx (plain shadcn fallback, already-resolved avatarUrl/name) +// +// Callers that already have `avatarUrl`/`fullName` (nav-user, profile-header) +// pass them directly and no lookup happens. Callers that only have a `userId` +// (comment threads) let this component resolve both via useUser(). +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { useUser as useIamUser } from "@/hooks/iam/use-users" + +const AVATAR_COLORS = [ + "bg-blue-100 text-blue-700", + "bg-emerald-100 text-emerald-700", + "bg-violet-100 text-violet-700", + "bg-orange-100 text-orange-700", + "bg-pink-100 text-pink-700", + "bg-cyan-100 text-cyan-700", +] + +/** Deterministic per-userId color hash — same user always gets the same color. */ +function avatarColor(userId: string): string { + const hash = (userId || "").split("").reduce((a, c) => a + c.charCodeAt(0), 0) + return AVATAR_COLORS[hash % AVATAR_COLORS.length] +} + +export interface UserAvatarProps { + /** IAM user id. When `avatarUrl`/`fullName` aren't passed directly, both are resolved via useUser(). */ + userId?: string | null + /** Pre-resolved full name — skips the name portion of the lookup when provided. */ + fullName?: string + /** Pre-resolved avatar URL — skips the avatar portion of the lookup when provided. */ + avatarUrl?: string | null + /** Applied to the outer Avatar element — controls size/shape, e.g. "h-8 w-8 rounded-lg". */ + className?: string + /** Applied to AvatarFallback — controls fallback text styling. */ + fallbackClassName?: string + /** + * When true, the fallback background uses the deterministic per-userId color hash + * (the look originally used in comment threads' `UserInitials`). Defaults to false, + * matching the plain shadcn muted fallback already used by nav-user/profile-header. + */ + colorHash?: boolean +} + +export function UserAvatar({ + userId, + fullName: fullNameProp, + avatarUrl: avatarUrlProp, + className, + fallbackClassName, + colorHash = false, +}: UserAvatarProps) { + // Only hit the network when the caller hasn't already resolved both fields + // (mirrors the enabled: !!id guard inside useUser/createCrudHooks). + const needsLookup = fullNameProp === undefined || avatarUrlProp === undefined + const { data: resp } = useIamUser(needsLookup ? userId || "" : "") + const detail = resp?.data?.detail + + const fullName = fullNameProp ?? detail?.fullName ?? "" + const avatarUrl = avatarUrlProp ?? detail?.profilePictureUrl ?? undefined + + const initials = fullName + ? fullName.trim().split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2) + : (userId || "?").charAt(0).toUpperCase() + + const hashClass = colorHash && userId ? avatarColor(userId) : "" + + return ( + + {avatarUrl && } + + {initials} + + + ) +} diff --git a/src/components/finance/cost-request-comment/comment-item.tsx b/src/components/finance/cost-request-comment/comment-item.tsx index a4df874..88b7080 100644 --- a/src/components/finance/cost-request-comment/comment-item.tsx +++ b/src/components/finance/cost-request-comment/comment-item.tsx @@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { MentionContent } from "@/components/common/mentionable-textarea" +import { UserAvatar } from "@/components/common/user-avatar" import { UserName } from "@/components/common/user-name" import { usePermissionContext } from "@/providers/permission-provider" import { @@ -32,7 +33,6 @@ import { import type { CostRequestComment } from "@/types/finance/cost-request-comment" import { AttachmentList } from "./attachment-list" -import { UserInitials } from "./comments-panel" interface Props { comment: CostRequestComment @@ -75,7 +75,12 @@ export function CommentItem({ comment, currentUserId }: Props) { return (
- +
diff --git a/src/components/finance/cost-request-comment/comments-panel.tsx b/src/components/finance/cost-request-comment/comments-panel.tsx index 3db1f6b..9d89c59 100644 --- a/src/components/finance/cost-request-comment/comments-panel.tsx +++ b/src/components/finance/cost-request-comment/comments-panel.tsx @@ -9,8 +9,8 @@ import { Loader2, Paperclip, Send, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { MentionableTextarea } from "@/components/common/mentionable-textarea" +import { UserAvatar } from "@/components/common/user-avatar" import { useAuth } from "@/providers/auth-provider" -import { useUser as useIamUser } from "@/hooks/iam/use-users" import { useCreateRequestComment, useRequestComments } from "@/hooks/finance/use-cost-request-comment" import { useUploadAttachment } from "@/hooks/finance/use-cost-attachment" @@ -22,36 +22,6 @@ interface Props { readOnly?: boolean } -const AVATAR_COLORS = [ - "bg-blue-100 text-blue-700", - "bg-emerald-100 text-emerald-700", - "bg-violet-100 text-violet-700", - "bg-orange-100 text-orange-700", - "bg-pink-100 text-pink-700", - "bg-cyan-100 text-cyan-700", -] - -function avatarColor(userId: string): string { - const hash = (userId || "").split("").reduce((a, c) => a + c.charCodeAt(0), 0) - return AVATAR_COLORS[hash % AVATAR_COLORS.length] -} - -export function UserInitials({ userId, className = "" }: { userId: string; className?: string }) { - const { data: resp } = useIamUser(userId || "") - const fullName: string = resp?.data?.detail?.fullName || "" - const initials = fullName - ? fullName.trim().split(/\s+/).map((w: string) => w[0]).join("").toUpperCase().slice(0, 2) - : (userId || "?").charAt(0).toUpperCase() - return ( -
- {initials} -
- ) -} - function wrapRichtext(plain: string): string { return JSON.stringify({ type: "doc", @@ -154,7 +124,12 @@ export function CommentsPanel({ requestId, readOnly = false }: Props) {
) : (
- +
n[0]) - .join("") - .toUpperCase() - .slice(0, 2) - return ( @@ -78,10 +67,12 @@ export function NavUser({ user }: NavUserProps) { data-testid="nav-user-trigger" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - - {initials} - +
{user.name} {user.email} @@ -97,10 +88,12 @@ export function NavUser({ user }: NavUserProps) { >
- - - {initials} - +
{user.name} {user.email} diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx index 57f8dd8..8e2e110 100644 --- a/src/components/profile/profile-header.tsx +++ b/src/components/profile/profile-header.tsx @@ -1,6 +1,6 @@ "use client" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { UserAvatar } from "@/components/common/user-avatar" import { Badge } from "@/components/ui/badge" import { Card, CardContent } from "@/components/ui/card" import { ShieldCheck, ShieldOff, Building2, Briefcase } from "lucide-react" @@ -24,24 +24,17 @@ export function ProfileHeader({ roles = [], twoFactorEnabled = false, }: ProfileHeaderProps) { - const initials = name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) - return (
{/* Avatar */} - - - - {initials} - - + {/* User Info */}
From 6ac86192c92cb4696dd4220b56010fe8bbe3e8a1 Mon Sep 17 00:00:00 2001 From: ilhom Date: Fri, 3 Jul 2026 20:14:06 +0700 Subject: [PATCH 02/23] feat(frontend): merge Classification + Feasibility into one sequential dialog; hide Use Existing Costing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace two separate single-step dialogs (VerifyClassification and Feasibility) with a single ClassificationAndFeasibilityDialog that submits both steps in sequence, shows partial-failure state when classification succeeds but feasibility fails, and retries only the failed step. Also hides the Use Existing Costing button in the UNDER_REVIEW action block — the transition existed but had no useful completion path for the user at that stage. Co-Authored-By: Claude Sonnet 4.6 --- .../finance/cost-product-request/index.ts | 2 +- .../request-detail-panel.tsx | 66 +---- .../transition-dialogs.tsx | 280 +++++++++++------- 3 files changed, 188 insertions(+), 160 deletions(-) diff --git a/src/components/finance/cost-product-request/index.ts b/src/components/finance/cost-product-request/index.ts index 341c953..ac4b756 100644 --- a/src/components/finance/cost-product-request/index.ts +++ b/src/components/finance/cost-product-request/index.ts @@ -2,6 +2,6 @@ export { RequestFormDialog } from "./request-form-dialog" export { RequestTable, buildColumns, useRequestTableColumns, TABLE_ID } from "./request-table" export { RequestDetailPanel } from "./request-detail-panel" export { StatusBadge } from "./status-badge" -export { CloseDialog, ConfirmActionDialog, FeasibilityDialog, ReasonDialog, UseExistingCostingDialog, VerifyClassificationDialog } from "./transition-dialogs" +export { ClassificationAndFeasibilityDialog, CloseDialog, ConfirmActionDialog, ReasonDialog, UseExistingCostingDialog } from "./transition-dialogs" export { UnlockPasswordDialog } from "./unlock-password-dialog" export { ParamSummaryPanel } from "./param-summary-panel" diff --git a/src/components/finance/cost-product-request/request-detail-panel.tsx b/src/components/finance/cost-product-request/request-detail-panel.tsx index 559acdc..1b2a21b 100644 --- a/src/components/finance/cost-product-request/request-detail-panel.tsx +++ b/src/components/finance/cost-product-request/request-detail-panel.tsx @@ -32,18 +32,15 @@ import { RoutingPanel } from "./routing-panel" import { StatusBadge } from "./status-badge" import { ParamSummaryPanel } from "./param-summary-panel" import { + ClassificationAndFeasibilityDialog, CloseDialog, ConfirmActionDialog, - FeasibilityDialog, ReasonDialog, - UseExistingCostingDialog, - VerifyClassificationDialog, } from "./transition-dialogs" import { useApproveRequest, useCancelRequest, useConfirmRequest, - useDecideFeasibility, useMarkParameterComplete, useMarkParameterPending, useReleaseRequest, @@ -52,8 +49,6 @@ import { useRejectRequest, useStartReview, useSubmitRequest, - useUseExistingCosting, - useVerifyClassification, useCloseRequest, } from "@/hooks/finance/use-cost-product-request" import type { CostProductRequest } from "@/types/finance/cost-product-request" @@ -72,7 +67,7 @@ interface Props { hasFillTracking?: boolean } -type DialogKind = "reject" | "cancel" | "verify" | "feasibility" | "close" | "useExisting" | "confirmAction" | null +type DialogKind = "reject" | "cancel" | "reviewDecide" | "close" | "confirmAction" | null export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, hasFillTracking = false }: Props) { useCPRRealtimeSync(request.requestId) @@ -84,9 +79,6 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, const startM = useStartReview() const reviseM = useReviseRequest() const reopenM = useReopenRequest() - const useExistingM = useUseExistingCosting() - const verifyM = useVerifyClassification() - const feasibilityM = useDecideFeasibility() const rejectM = useRejectRequest() const cancelM = useCancelRequest() const closeM = useCloseRequest() @@ -193,25 +185,9 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, Promote route )} - {isUnderReview && canResolve && !request.verifiedClassification && ( - - )} {isUnderReview && canResolve && ( - - )} - {isUnderReview && canResolve && - (request.verifiedClassification === "existing" || - (!request.verifiedClassification && request.productClassification === "existing")) && ( - )} {(isSubmitted || isUnderReview) && canReject && ( @@ -508,36 +484,12 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, cancelM.mutate({ requestId, body: { reason } }, { onSuccess: () => setDialog(null) }) }} /> - setDialog(o ? "verify" : null)} + setDialog(o ? "reviewDecide" : null)} + requestId={requestId} currentClassification={request.productClassification} - pending={verifyM.isPending} - onConfirm={(verified, overrideReason) => { - verifyM.mutate( - { requestId, verifiedClassification: verified, overrideReason }, - { onSuccess: () => setDialog(null) }, - ) - }} - /> - setDialog(o ? "feasibility" : null)} - pending={feasibilityM.isPending} - onConfirm={(decision, note) => { - feasibilityM.mutate({ requestId, decision, note }, { onSuccess: () => setDialog(null) }) - }} - /> - setDialog(o ? "useExisting" : null)} - pending={useExistingM.isPending} - onConfirm={(existingProductSysId) => { - useExistingM.mutate( - { requestId, body: { existingProductSysId } }, - { onSuccess: () => setDialog(null) }, - ) - }} + initialVerifiedClassification={request.verifiedClassification} /> "classifying" (step 1 in flight) +// -> "classified" (step 1 saved; either about to auto-run step 2, or step 2 failed and the +// user is retrying just feasibility, with classification now read-only) -> "deciding" (step 2 +// in flight) -> "done" (both succeeded, dialog closes). `error` carries the latest inline +// message; it is distinct from the toasts the underlying hooks already fire on their own +// success/failure. +type ReviewPhase = "idle" | "classifying" | "classified" | "deciding" | "done" + +interface ClassificationAndFeasibilityProps { open: boolean onOpenChange: (open: boolean) => void + requestId: number + /** productClassification — the system/marketing-predicted value. */ currentClassification: ProductClassification - pending?: boolean - onConfirm: (verified: ProductClassification, overrideReason: string) => void + /** request.verifiedClassification, if already set — used to pre-fill instead of the prediction. */ + initialVerifiedClassification?: ProductClassification } -export function VerifyClassificationDialog({ open, onOpenChange, currentClassification, pending, onConfirm }: VerifyProps) { - const [verified, setVerified] = useState(currentClassification) - const [overrideReason, setOverrideReason] = useState("") - const isOverride = verified !== currentClassification - - return ( - { - onOpenChange(o) - if (!o) { - setVerified(currentClassification) - setOverrideReason("") - } - }} - > - - - Verify classification - - Marketing marked this as {currentClassification}. Confirm or override; an override - requires a reason. - - -
- setVerified(v as ProductClassification)} - className="flex gap-6" - > - - - - {isOverride && ( -
- -