fix mobile screen issues

This commit is contained in:
2026-08-24 13:14:22 +05:30
parent dcf9770ada
commit 02eb48af99
34 changed files with 1214 additions and 291 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/lib/AuthContext';
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
@@ -9,8 +9,24 @@ const DefaultFallback = () => (
</div>
);
/**
* `unauthenticatedElement` defaults to the login page.
*
* It used to default to `undefined`, which renders nothing. That was invisible
* while the data client reported the seeded user as permanently signed in and
* this branch was unreachable; now that `GET /me` can genuinely answer 401, the
* default is what a signed-out visitor actually sees, and a blank screen is not
* an acceptable answer to "you are not signed in".
*
* The attempted path travels in location state so signing in returns the
* visitor to where they were going.
*/
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
const location = useLocation();
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
const signedOut = unauthenticatedElement ?? (
<Navigate to="/admin/login" replace state={{ from: location.pathname }} />
);
useEffect(() => {
if (!authChecked && !isLoadingAuth) {
@@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthe
if (authError.type === 'user_not_registered') {
return <UserNotRegisteredError />;
}
return unauthenticatedElement;
return signedOut;
}
if (!isAuthenticated) {
return unauthenticatedElement;
return signedOut;
}
return <Outlet />;

View File

@@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2.5">
{/* Wraps rather than compresses. The row carries three things of very
different lengths — the page name, its count and the status pill —
and on a 390px screen holding them on one line meant the count
breaking mid-phrase and the pill splitting into "Live / System".
Nothing wraps at any width where all three fit, so every desktop
layout is untouched. */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
{/* A count of whatever the page is a list of, stated beside its
name rather than only inside the list. */}
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System
</span>
@@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
export function SectionTitle({ id, title, meta, action, className }) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
<div className="flex items-baseline gap-2">
{/* The count wraps under the section name rather than competing with it
for a 390px line. On any width where both fit — every desktop and
tablet layout — nothing wraps and the row is the row it always was. */}
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider';
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
const EXPANDED_WIDTH = 620;
@@ -18,32 +19,6 @@ const LEAD_PADDING = 8;
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
/** Expanded must never dominate: the dashboard stays the primary experience. */
const MAX_VIEWPORT_SHARE = 0.42;
/**
* Below this width there is no room for a column *beside* the dashboard — a
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
* underneath the page instead.
*
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
* column out acceptably, so they keep the two-column workspace and only phones
* stack.
*/
const STACK_BREAKPOINT = 768;
/** Tracks viewport width so the panel can be clamped and the layout switched. */
function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/**
* The collapsed state — a compact docked trigger.
*
@@ -64,7 +39,12 @@ function CollapsedTrigger({ page, onRestore }) {
onClick={onRestore}
aria-label={`Show the Owliver workspace for ${page}`}
aria-expanded={false}
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
/* `bottom` is a `max()` against the bottom safe-area inset rather than a
flat 20px: on a phone with a home indicator a flat offset puts the pill
under the gesture bar, where the tap belongs to the OS. `env()` is 0
everywhere else, so desktop keeps the offset it always had. */
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))]
z-30 inline-flex items-center gap-2 rounded-full border border-border
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
hover:border-krow-blue/40 hover:shadow-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
@@ -77,11 +57,105 @@ function CollapsedTrigger({ page, onRestore }) {
}
/**
* AssistantPanel — the Owliver workspace column the Admin layout renders.
* MobileWorkspace — Owliver on a phone.
*
* Owliver is part of the page on every supported route, so this is a column in
* the layout rather than an overlay: the dashboard reflows beside it instead of
* being covered, and collapsing restores the original layout exactly.
* The same `KrowAssistant`, the same context, the same runtime, the same
* conversation. What changes is only where it is mounted: on a phone the
* workspace is an overlay above the page rather than a column beside it.
*
* That is forced by arithmetic, not taste. The desktop workspace is
* `main + 400px`; at 375px the 400px track leaves `main` negative, so the two
* surfaces stop being a layout and start being a fight over the same pixels —
* which is exactly what the broken state was. An overlay takes the page out of
* that arithmetic entirely: the page stays `width: 100%` whether Owliver is
* open or closed, and there is never a reserved column standing empty.
*
* Three things this is deliberately not:
*
* - Not a second chat. Nothing about the assistant is re-implemented; this
* component is a positioned container and nothing else.
* - Not a takeover. It stops below the app header, so the reader can still see
* where they are and can still leave.
* - Not a fixed height. It is sized to `visualViewport` where that exists and
* to `100dvh` where it does not, so an open keyboard shortens the sheet
* instead of pushing the composer off the bottom of it.
*/
function MobileWorkspace({ context, onClose }) {
const viewport = useVisualViewport();
/* The page behind an overlay must not scroll: on a touch screen a drag that
starts on the scrim and lands on the page is otherwise indistinguishable
from scrolling the conversation, and the reader loses their place on both
surfaces at once. Restored exactly as found — another overlay may already
own it. */
React.useEffect(() => {
const { body } = document;
const previous = body.style.overflow;
body.style.overflow = 'hidden';
return () => { body.style.overflow = previous; };
}, []);
return (
<div
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
>
{/* Tapping the page dismisses, which is what a sheet over a page should
do. A button rather than a bare div so it is reachable without a
pointer. */}
<button
type="button"
aria-label="Close the Owliver workspace"
onClick={onClose}
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
/>
{/* `top-14` is the header's own height: the sheet starts under the app
bar rather than over it, so navigation is never covered. `min-h-0` is
what lets the assistant's internal scroller own the overflow instead
of the sheet growing past the viewport. */}
<div
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
role="dialog"
aria-modal="true"
aria-label="Owliver workspace"
>
<KrowAssistant
key={context.id}
context={context}
/* Expanded is a desktop-only width state; there is no wider to go
here, so the control is not offered rather than offered and inert. */
expanded={false}
onClose={onClose}
onExpand={null}
onRestore={null}
className="h-full min-h-0"
/>
</div>
</div>
);
}
/**
* AssistantPanel — the Owliver workspace the Admin layout renders.
*
* One panel, two presentations, chosen by how much room there is beside the
* page rather than by what kind of device is asking:
*
* ≥ 768px a column in the layout. Owliver is part of the page on every
* supported route, so the dashboard reflows beside it instead of
* being covered, and collapsing restores the original layout
* exactly. This is the protected desktop geometry and everything
* below describes it.
* < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit
* beside anything on a 375px phone. The page is `width: 100%` in
* both states and never participates in a two-column width
* calculation it cannot satisfy.
*
* Both presentations mount the same `KrowAssistant` with the same context and
* read the same open/collapsed state, so there is one assistant in the product
* and one set of actions that change it.
*
* Five structural details matter, and every one of them was a bug at some point:
*
@@ -111,10 +185,24 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
setWidth, resetWidth, open, close, expand, restore,
} = useAssistantPanel();
const viewportWidth = useViewportWidth();
const isPhone = useIsPhone();
// No assistant on this route: no column, no rail, no trace in the layout.
if (!context) return null;
/* Phones: Owliver is never a column, in either state.
Closed, the layout is one column and `main` has the whole viewport — there
is no reserved 400px gutter to leave a blank strip down the right. Open,
the workspace is an overlay, so the page keeps that full width underneath
rather than being asked to share it with a track wider than the phone.
Both states are rendered from the same panel state the desktop column uses,
so opening, collapsing and reopening are the same three actions here. */
if (isPhone) {
return isOpen
? <MobileWorkspace context={context} onClose={close} />
: <CollapsedTrigger page={context.page} onRestore={open} />;
}
/* Expanded overrides the dragged width; otherwise the user's own width wins.
Both are clamped against the viewport so the dashboard is never squeezed. */
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
@@ -131,25 +219,6 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const trackWidth = panelWidth + GUTTER;
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
not a layout at this width, and an overlay that opens on load would put a
sheet between the user and the page they asked for. */
if (viewportWidth < STACK_BREAKPOINT) {
return (
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={null}
onRestore={null}
className="h-[32rem]"
/>
</aside>
);
}
return (
<aside
aria-label="Owliver workspace"

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PHONE_QUERY } from './viewport';
import { PageContextProvider } from './PageContext';
import { AgentProvider } from './AgentContext';
@@ -58,6 +59,24 @@ function writeFlag(key, value) {
}
}
/**
* Whether this session is starting on a phone.
*
* Read once, to pick the *default* — never to decide how the panel renders.
* That decision belongs to `AssistantPanel`, which watches the same query and
* re-renders when it changes; a resize past the breakpoint must move the panel
* between presentations without silently reopening or closing it.
*
* The same media query the layout switches on, imported rather than repeated.
*/
function startsOnPhone() {
try {
return window.matchMedia(PHONE_QUERY).matches;
} catch {
return false;
}
}
export function AssistantPanelProvider({ role, pathname, children }) {
/* Two different things, deliberately kept apart: the account preference is
whether Owliver *should* open by default, and the session flag is whether
@@ -65,8 +84,19 @@ export function AssistantPanelProvider({ role, pathname, children }) {
panel collapsed for the afternoon does not rewrite the account setting, and
an account with Owliver off does not get the panel pushed back at it on
every new tab. */
/* The default differs by presentation, and only the default.
On the desktop workspace Owliver *is* part of the page, so it opens with
it. On a phone the workspace is an overlay (see AssistantPanel), and an
overlay that opens on load is a sheet between the reader and the page they
asked for — so a phone starts on the docked pill, one tap from the same
conversation. Everything after that first render is the shared state:
opening on a phone is the same `open()` the desktop header calls, and the
session remembers it either way. */
const [open, setOpen] = React.useState(
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
() => readFlag(
OPEN_KEY,
!startsOnPhone() && base44.auth.preferences().owliverDefault !== false
)
);
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const [width, setWidth] = React.useState(() => {

View File

@@ -0,0 +1,102 @@
/**
* Viewport facts the Owliver workspace is laid out from.
*
* Separate from both the panel and its context because both need them: the
* panel decides which presentation to render, the provider decides which state
* to start in, and a breakpoint that two modules each keep their own copy of is
* a breakpoint that will eventually disagree with itself. Keeping it here also
* keeps the two from importing each other in a cycle.
*/
import * as React from 'react';
/**
* Below this width there is no room for a column *beside* the dashboard — a
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stops
* being a column at all and becomes an overlay over the page.
*
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
* column out acceptably, so they keep the two-column workspace and only phones
* change presentation. Exported so the provider can start a phone with the
* workspace closed from the same number the layout switches on — two places
* asking "is this a phone" must never be able to disagree.
*/
export const STACK_BREAKPOINT = 768;
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
/** Tracks viewport width so the panel can be clamped. */
export function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/**
* Whether the shell is in its phone layout.
*
* `matchMedia` rather than a width comparison, so the switch happens on exactly
* the same boundary the stylesheet uses. `window.innerWidth` is not that
* boundary on a phone: when anything on the page overflows, mobile browsers
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
* and the layout that was supposed to rescue the page reads the viewport as a
* tablet and hands it a 400px sidebar instead. A media query is evaluated
* against the media, not against the content, so it cannot be talked out of the
* mobile layout by the very overflow the mobile layout exists to prevent.
*/
export function useIsPhone() {
const [phone, setPhone] = React.useState(() => {
try {
return window.matchMedia(PHONE_QUERY).matches;
} catch {
return false;
}
});
React.useEffect(() => {
const mql = window.matchMedia(PHONE_QUERY);
const onChange = (e) => setPhone(e.matches);
setPhone(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return phone;
}
/**
* The height the user can actually see, while a keyboard is open.
*
* `100dvh` follows the address bar but knows nothing about the on-screen
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
* puts its own composer underneath the keys — the one control the sheet exists
* to offer. `visualViewport` is the region left over, so the sheet is sized to
* that and the composer stays on screen.
*
* Returns `null` where the API is missing, and the caller falls back to the
* `100dvh` class. Nothing here is a fixed pixel height.
*/
export function useVisualViewport() {
const [box, setBox] = React.useState(null);
React.useEffect(() => {
const vv = window.visualViewport;
if (!vv) return undefined;
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
return box;
}

View File

@@ -456,7 +456,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
className="overflow-hidden rounded-xl border border-border bg-surface"
>
<div className="overflow-x-auto">
<table className="w-full text-left text-body-sm">
<table className="w-full min-w-[40rem] text-left text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
<th className="px-4 py-3">Rank</th>

View File

@@ -194,7 +194,7 @@ export function DataTable({
<LoadingOverlay show={refreshing} />
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<table className="w-full min-w-[36rem] border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>

View File

@@ -83,7 +83,14 @@ export function MetricStrip({ items = [], columns, className, loading = false })
<div>
{/* Header: Label & Icon */}
<div className="flex items-center justify-between gap-1.5">
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
{/* Wraps on a phone, clips on everything wider.
Two of these sit side by side at 320px, which leaves about
110px for the label — enough for "OPEN POSITIONS" over two
lines and not enough for it on one, so a flat `truncate`
turned every metric on the Control Center into "OPEN POSI…".
From `sm` the strip is three or six across with room for the
label on one line, which is where clipping was the intent. */}
<p className="min-w-0 line-clamp-2 text-[10px] font-bold uppercase tracking-wider text-ink-4 sm:line-clamp-1">
{item.label}
</p>
{IconComponent && (

View File

@@ -43,7 +43,11 @@ export function Modal({
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
/* `dvh`, not `vh`: on a phone `100vh` is the *large* viewport, which
includes the browser chrome that is currently covering the bottom of
the screen — so a `90vh` dialog put its footer, and its confirm
button, under the address bar. */
className={cn('p-0 gap-0 overflow-hidden max-h-[calc(100dvh-2rem)] flex flex-col', WIDTHS[size], className)}
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
>

View File

@@ -80,7 +80,7 @@ export default function ActivityLogTable({ activities }) {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<table className="w-full min-w-[40rem] text-[13px]">
<thead className="bg-[#F9FAFB] text-[#6B7280]">
<tr>
<th className="text-left font-medium px-4 py-2.5">Event</th>

View File

@@ -139,8 +139,16 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
return (
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
{/* The row wraps below `sm`, and only below `sm`.
Its fixed furniture — rank, portrait, score ring and the action
cluster — adds up to about 400px before the name has a single pixel,
so on a 320px phone the flexible middle was being squeezed to nothing
and the candidate's name truncated to one character while the buttons
hung off the side of the screen. Wrapping gives the name the width and
moves the controls to their own line; `sm:flex-nowrap` restores the
single desktop row exactly. */}
<div
className="flex items-center gap-4 cursor-pointer"
className="flex flex-wrap items-center gap-3 cursor-pointer sm:flex-nowrap sm:gap-4"
onClick={() => setExpanded(!expanded)}
>
{/* Rank */}
@@ -149,7 +157,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
{/* Avatar — 88-110px target size */}
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<Image
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
alt={application.applicant_name}
@@ -158,8 +166,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
floor to claim on a phone — under it the row wraps instead of
shaving the one column that carries meaning. */}
<div className="flex-1 basis-40 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
@@ -201,76 +211,84 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
)}
</div>
{/* Score */}
{status !== 'applied' && <ScoreRing score={score} />}
{/* Score and actions.
`sm:contents` dissolves this wrapper from the tablet breakpoint up,
so above `sm` the ring and the buttons are direct children of the
row exactly as they always were — same order, same gaps, same
desktop geometry. Below it they travel together onto the row's
second line. */}
<div className="flex w-full items-center justify-between gap-3 sm:contents">
{/* Score */}
{status !== 'applied' && <ScoreRing score={score} />}
{/* Actions */}
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* The full record, at the route that already serves it.
Clicking the row expands a summary in place, which is the right
default while comparing people — but the complete profile had no
way in from this list at all, so the same candidate opened from a
recommendation and from here led to two different depths of the
same person. One link, one route, one profile. */}
<button
type="button"
onClick={() => navigate(`/admin/candidates/${application.id}`)}
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
View profile
<ArrowRight className="w-3 h-3" aria-hidden="true" />
</button>
{onAction && canTakeAction && (
{/* Actions */}
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* The full record, at the route that already serves it.
Clicking the row expands a summary in place, which is the right
default while comparing people — but the complete profile had no
way in from this list at all, so the same candidate opened from a
recommendation and from here led to two different depths of the
same person. One link, one route, one profile. */}
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
type="button"
onClick={() => navigate(`/admin/candidates/${application.id}`)}
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
View profile
<ArrowRight className="w-3 h-3" aria-hidden="true" />
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
Actions
<ChevronDown className="w-3.5 h-3.5" />
{onAction && canTakeAction && (
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
Actions
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{expanded && <CandidateExpandedDetails application={application} />}

View File

@@ -71,7 +71,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
<DialogHeader className="sr-only">
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
</DialogHeader>

View File

@@ -164,7 +164,7 @@ function TableSection({ data }) {
return (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-body-sm">
<table className="w-full min-w-[32rem] border-collapse text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (

View File

@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:w-full data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>