feat: full responsive layout — mobile sidebar, stacked panels, fullscreen drawers
- AppShell: permanent sidebar on desktop, temporary drawer + hamburger on mobile (<md) - TopBar: hamburger icon on mobile, AI-Assistent als Icon-Button auf kleinen Screens - MatchDetail: Sidebar stacks below content on mobile, key facts wrap, px responsive - Properties: Drawer fullscreen (100vw) on mobile, 650px on desktop - ActiveInquiriesTab: mobile shows list → tap opens detail with back button - LatentInquiriesTab: mobile sequential navigation (list → detail → own properties), tablet 2-col, desktop 3-col - PublicNeedList: fullWidth prop for mobile layout - InquiryDetailPanel: property card stacks below chat on mobile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Box, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { InquiryList } from './InquiryList'
|
import { InquiryList } from './InquiryList'
|
||||||
@@ -21,6 +21,8 @@ export function ActiveInquiriesTab() {
|
|||||||
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<ViewMode>(loadViewMode())
|
const [view, setView] = useState<ViewMode>(loadViewMode())
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -28,12 +30,12 @@ export function ActiveInquiriesTab() {
|
|||||||
}
|
}
|
||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
// Auto-select first inquiry when data loads
|
// Auto-select first inquiry on desktop only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId && inquiries.length > 0) {
|
if (!isMobile && !selectedId && inquiries.length > 0) {
|
||||||
setSelectedId(inquiries[0].id)
|
setSelectedId(inquiries[0].id)
|
||||||
}
|
}
|
||||||
}, [inquiries, selectedId])
|
}, [inquiries, selectedId, isMobile])
|
||||||
|
|
||||||
if (!isLoading && inquiries.length === 0) {
|
if (!isLoading && inquiries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -45,15 +47,31 @@ export function ActiveInquiriesTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: show detail panel when an inquiry is selected
|
||||||
|
if (isMobile && selectedId) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
||||||
|
<IconButton size="small" onClick={() => setSelectedId(null)}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<InquiryDetailPanel inquiryId={selectedId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Left column: list/grid */}
|
{/* Left column: list/grid */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: view === 'list' ? 380 : 720,
|
width: isMobile ? '100%' : (view === 'list' ? 380 : 720),
|
||||||
minWidth: view === 'list' ? 380 : 480,
|
minWidth: isMobile ? 'unset' : (view === 'list' ? 380 : 480),
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: '1px solid #e2e8f0',
|
borderRight: isMobile ? 'none' : '1px solid #e2e8f0',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -75,44 +93,18 @@ export function ActiveInquiriesTab() {
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
Anfragen
|
Anfragen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#1e3a5f', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||||
sx={{
|
|
||||||
px: 1,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
color: 'white',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inquiries.length}
|
{inquiries.length}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
<Tooltip title="Listenansicht">
|
<Tooltip title="Listenansicht">
|
||||||
<IconButton
|
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#1e3a5f' : '#64748b' }}>
|
||||||
size="small"
|
|
||||||
onClick={() => setView('list')}
|
|
||||||
sx={{
|
|
||||||
bgcolor: view === 'list' ? '#e0e7ff' : 'transparent',
|
|
||||||
color: view === 'list' ? '#1e3a5f' : '#64748b',
|
|
||||||
'&:hover': { bgcolor: view === 'list' ? '#c7d2fe' : '#f1f5f9' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListIcon size={16} />
|
<ListIcon size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Kartenansicht">
|
<Tooltip title="Kartenansicht">
|
||||||
<IconButton
|
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#1e3a5f' : '#64748b' }}>
|
||||||
size="small"
|
|
||||||
onClick={() => setView('grid')}
|
|
||||||
sx={{
|
|
||||||
bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent',
|
|
||||||
color: view === 'grid' ? '#1e3a5f' : '#64748b',
|
|
||||||
'&:hover': { bgcolor: view === 'grid' ? '#c7d2fe' : '#f1f5f9' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={16} />
|
<LayoutGrid size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -120,32 +112,26 @@ export function ActiveInquiriesTab() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{view === 'list' ? (
|
{view === 'list' ? (
|
||||||
<InquiryList
|
<InquiryList inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
inquiries={inquiries}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={setSelectedId}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<InquiryCardGrid
|
<InquiryCardGrid inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
inquiries={inquiries}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={setSelectedId}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right column: detail */}
|
{/* Right column: detail (desktop only) */}
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
{!isMobile && (
|
||||||
{selectedId ? (
|
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
<InquiryDetailPanel inquiryId={selectedId} />
|
{selectedId ? (
|
||||||
) : (
|
<InquiryDetailPanel inquiryId={selectedId} />
|
||||||
<EmptyState
|
) : (
|
||||||
icon={<Inbox size={40} />}
|
<EmptyState
|
||||||
title="Anfrage auswählen"
|
icon={<Inbox size={40} />}
|
||||||
description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
|
title="Anfrage auswählen"
|
||||||
/>
|
description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
|
||||||
)}
|
/>
|
||||||
</Box>
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
|
||||||
<InquiryChat inquiry={inquiry} />
|
<InquiryChat inquiry={inquiry} />
|
||||||
<Box sx={{ width: 300, flexShrink: 0 }}>
|
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
|
||||||
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
|
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,41 +1,117 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { ArrowLeft, Sparkles } from 'lucide-react'
|
||||||
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
|
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { PublicNeedList } from './PublicNeedList'
|
import { PublicNeedList } from './PublicNeedList'
|
||||||
import { PublicNeedDetail } from './PublicNeedDetail'
|
import { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||||
|
|
||||||
|
type MobileView = 'list' | 'detail' | 'properties'
|
||||||
|
|
||||||
export function LatentInquiriesTab() {
|
export function LatentInquiriesTab() {
|
||||||
const { data: needs = [] } = usePublicNeeds()
|
const { data: needs = [] } = usePublicNeeds()
|
||||||
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
|
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
|
||||||
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
|
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'))
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>('list')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedNeedId && needs.length > 0) {
|
if (!isMobile && !selectedNeedId && needs.length > 0) {
|
||||||
setSelectedNeedId(needs[0].id)
|
setSelectedNeedId(needs[0].id)
|
||||||
}
|
}
|
||||||
}, [needs, selectedNeedId])
|
}, [needs, selectedNeedId, isMobile])
|
||||||
|
|
||||||
|
const handleSelectNeed = (id: string) => {
|
||||||
|
setSelectedNeedId(id)
|
||||||
|
if (isMobile) setMobileView('detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: sequential single-panel navigation
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === 'detail' && selectedNeed) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<IconButton size="small" onClick={() => setMobileView('list')}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>
|
||||||
|
{selectedNeed.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
|
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setMobileView('properties')}
|
||||||
|
sx={{ p: 1.25, bgcolor: '#1e3a5f', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Eigene Objekte anzeigen →
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mobileView === 'properties' && selectedNeed) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<IconButton size="small" onClick={() => setMobileView('detail')}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||||
|
Eigene Objekte
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<OwnPropertyMatchList need={selectedNeed} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// list view
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', overflow: 'hidden' }}>
|
||||||
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={handleSelectNeed} fullWidth />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tablet: hide own-property column, show it as button in detail
|
||||||
|
if (isTablet) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{selectedNeed ? (
|
||||||
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details zu sehen." />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: full 3-column layout
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
{selectedNeed ? (
|
{selectedNeed ? (
|
||||||
<PublicNeedDetail need={selectedNeed} />
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<EmptyState
|
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen." />
|
||||||
icon={<Sparkles size={40} />}
|
|
||||||
title="Bedarf auswählen"
|
|
||||||
description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen."
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
|
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,18 +7,19 @@ import { EmptyState } from '../ui'
|
|||||||
interface PublicNeedListProps {
|
interface PublicNeedListProps {
|
||||||
selectedNeedId: string | null
|
selectedNeedId: string | null
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
|
fullWidth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicNeedList({ selectedNeedId, onSelect }: PublicNeedListProps) {
|
export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNeedListProps) {
|
||||||
const { data: needs = [], isLoading, error } = usePublicNeeds()
|
const { data: needs = [], isLoading, error } = usePublicNeeds()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 280,
|
width: fullWidth ? '100%' : 280,
|
||||||
minWidth: 280,
|
minWidth: fullWidth ? 'unset' : 280,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: '1px solid #e2e8f0',
|
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
||||||
import { useLayoutStore } from '../../stores/layoutStore'
|
import { useLayoutStore } from '../../stores/layoutStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { WorkspaceType } from '../../domain/enums'
|
import { WorkspaceType } from '../../domain/enums'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Drawer,
|
||||||
Typography,
|
Typography,
|
||||||
Avatar,
|
Avatar,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Chip,
|
Chip,
|
||||||
Button,
|
Button,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -33,6 +36,7 @@ import {
|
|||||||
ServerCog,
|
ServerCog,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Menu,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
@@ -169,6 +173,7 @@ interface SidebarProps {
|
|||||||
allowedWorkspaces: WorkspaceType[]
|
allowedWorkspaces: WorkspaceType[]
|
||||||
onWorkspaceClick: (workspace: WorkspaceType) => void
|
onWorkspaceClick: (workspace: WorkspaceType) => void
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
|
onClose?: () => void
|
||||||
userName: string
|
userName: string
|
||||||
orgName: string
|
orgName: string
|
||||||
}
|
}
|
||||||
@@ -179,6 +184,7 @@ function Sidebar({
|
|||||||
allowedWorkspaces,
|
allowedWorkspaces,
|
||||||
onWorkspaceClick,
|
onWorkspaceClick,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onClose,
|
||||||
userName,
|
userName,
|
||||||
orgName,
|
orgName,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
@@ -260,7 +266,7 @@ function Sidebar({
|
|||||||
|
|
||||||
const tabContent = (
|
const tabContent = (
|
||||||
<Box
|
<Box
|
||||||
onClick={() => onWorkspaceClick(ws)}
|
onClick={() => { onWorkspaceClick(ws); onClose?.() }}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -313,6 +319,7 @@ function Sidebar({
|
|||||||
<NavLink
|
<NavLink
|
||||||
to={item.path}
|
to={item.path}
|
||||||
style={{ textDecoration: 'none', display: 'block' }}
|
style={{ textDecoration: 'none', display: 'block' }}
|
||||||
|
onClick={() => onClose?.()}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<Box
|
<Box
|
||||||
@@ -434,9 +441,11 @@ function Sidebar({
|
|||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
activeWorkspace: WorkspaceType
|
activeWorkspace: WorkspaceType
|
||||||
pathname: string
|
pathname: string
|
||||||
|
onMenuClick?: () => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: TopBarProps) {
|
||||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||||
const pageName = getPageNameFromPath(pathname)
|
const pageName = getPageNameFromPath(pathname)
|
||||||
const openAssistant = useAssistantStore(s => s.open)
|
const openAssistant = useAssistantStore(s => s.open)
|
||||||
@@ -452,11 +461,16 @@ function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
px: 3,
|
px: { xs: 1.5, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||||
|
<Menu size={20} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
label={config.label}
|
label={config.label}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -470,24 +484,29 @@ function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
|||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
sx={{ fontWeight: 500, color: '#1e293b', fontSize: '0.9375rem' }}
|
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
|
||||||
>
|
>
|
||||||
{pageName}
|
{pageName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
||||||
<OrganizationContextBadge />
|
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||||
|
<OrganizationContextBadge />
|
||||||
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<Sparkles size={14} />}
|
startIcon={<Sparkles size={14} />}
|
||||||
onClick={openAssistant}
|
onClick={openAssistant}
|
||||||
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
sx={{ textTransform: 'none', fontSize: '0.8125rem', display: { xs: 'none', md: 'flex' } }}
|
||||||
>
|
>
|
||||||
AI Assistent
|
AI Assistent
|
||||||
</Button>
|
</Button>
|
||||||
|
<IconButton size="small" onClick={openAssistant} sx={{ display: { xs: 'flex', md: 'none' } }}>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</IconButton>
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -505,6 +524,12 @@ export function AppShell() {
|
|||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
// Close mobile menu on route change
|
||||||
|
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
// Sync active workspace with URL
|
// Sync active workspace with URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -523,20 +548,44 @@ export function AppShell() {
|
|||||||
const orgName = currentUser?.organizationName ?? ''
|
const orgName = currentUser?.organizationName ?? ''
|
||||||
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<Sidebar
|
||||||
|
collapsed={isMobile ? false : sidebarCollapsed}
|
||||||
|
activeWorkspace={activeWorkspace}
|
||||||
|
allowedWorkspaces={allowedWorkspaces}
|
||||||
|
onWorkspaceClick={handleWorkspaceClick}
|
||||||
|
onToggle={isMobile ? () => setMobileOpen(false) : toggleSidebar}
|
||||||
|
onClose={isMobile ? () => setMobileOpen(false) : undefined}
|
||||||
|
userName={userName}
|
||||||
|
orgName={orgName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
<Sidebar
|
{/* Desktop: permanent sidebar */}
|
||||||
collapsed={sidebarCollapsed}
|
{!isMobile && sidebarContent}
|
||||||
activeWorkspace={activeWorkspace}
|
|
||||||
allowedWorkspaces={allowedWorkspaces}
|
{/* Mobile: temporary drawer */}
|
||||||
onWorkspaceClick={handleWorkspaceClick}
|
{isMobile && (
|
||||||
onToggle={toggleSidebar}
|
<Drawer
|
||||||
userName={userName}
|
open={mobileOpen}
|
||||||
orgName={orgName}
|
onClose={() => setMobileOpen(false)}
|
||||||
/>
|
variant="temporary"
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
slotProps={{ paper: { sx: { width: 240, bgcolor: 'transparent', boxShadow: 'none' } } }}
|
||||||
|
>
|
||||||
|
{sidebarContent}
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<TopBar activeWorkspace={activeWorkspace} pathname={location.pathname} />
|
<TopBar
|
||||||
|
activeWorkspace={activeWorkspace}
|
||||||
|
pathname={location.pathname}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onMenuClick={() => setMobileOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -207,8 +207,8 @@ export default function MatchDetail() {
|
|||||||
|
|
||||||
{/* Property header — white section below hero */}
|
{/* Property header — white section below hero */}
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0' }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 2 }}>
|
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 2.5, pb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'flex-start', sm: 'flex-start' }, justifyContent: 'space-between', gap: 2 }}>
|
||||||
{/* Left: title + address + chips */}
|
{/* Left: title + address + chips */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
|
||||||
@@ -264,12 +264,13 @@ export default function MatchDetail() {
|
|||||||
|
|
||||||
{/* Key facts strip */}
|
{/* Key facts strip */}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ display: 'flex', px: 3, py: 1.5, gap: 0 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', px: { xs: 2, sm: 3 }, py: 1.5, gap: 0 }}>
|
||||||
{keyFacts.map((fact, i) => (
|
{keyFacts.map((fact, i) => (
|
||||||
<Box key={fact.label} sx={{
|
<Box key={fact.label} sx={{
|
||||||
flex: 1,
|
flex: '1 1 120px',
|
||||||
pl: i === 0 ? 0 : 2,
|
pl: i === 0 ? 0 : { xs: 1.5, sm: 2 },
|
||||||
pr: 2,
|
pr: { xs: 1.5, sm: 2 },
|
||||||
|
py: { xs: 0.5, sm: 0 },
|
||||||
borderLeft: i === 0 ? 'none' : '1px solid #e2e8f0',
|
borderLeft: i === 0 ? 'none' : '1px solid #e2e8f0',
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>
|
||||||
@@ -284,7 +285,7 @@ export default function MatchDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<Box sx={{ px: 3, py: 3, display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
<Box sx={{ px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, alignItems: 'flex-start' }}>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* Main column */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
@@ -340,7 +341,7 @@ export default function MatchDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} />
|
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} />
|
||||||
<NextActionsPanel
|
<NextActionsPanel
|
||||||
match={match}
|
match={match}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Drawer } from '@mui/material'
|
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { PageHeader } from '../../components/layout'
|
import { PageHeader } from '../../components/layout'
|
||||||
import { DecisionContextPanel } from '../../components/ui'
|
import { DecisionContextPanel } from '../../components/ui'
|
||||||
@@ -49,6 +49,8 @@ function applyFilters(properties: Property[], filters: PropertyTableFilters): Pr
|
|||||||
|
|
||||||
export default function Properties() {
|
export default function Properties() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||||
@@ -155,7 +157,7 @@ export default function Properties() {
|
|||||||
anchor="right"
|
anchor="right"
|
||||||
open={!!selectedId}
|
open={!!selectedId}
|
||||||
onClose={() => setSelectedId(null)}
|
onClose={() => setSelectedId(null)}
|
||||||
slotProps={{ paper: { sx: { width: 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
||||||
>
|
>
|
||||||
{selectedId && (
|
{selectedId && (
|
||||||
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
||||||
|
|||||||
Reference in New Issue
Block a user