feat(services): F005 – complete service layer
New domain types: - shortlist.ts: Shortlist, ShortlistItem, CreateShortlistInput - review.ts: ReviewQueueItem, ReviewPriority, ReviewQueueStatus New mock data: - shortlists.ts: 2 sample shortlists - reviewQueue.ts: 3 pending review items New providers (interface + mockup): - IShortlistProvider / MockupShortlistProvider (with 150ms latency) - IReviewProvider / MockupReviewProvider (priority-sorted, with latency) - IDashboardProvider / MockupDashboardProvider (computed from all mock data) New services: - reviewService: getQueue, approve, reject, assign - shortlistService: CRUD + addItem/removeItem - authService: wraps sessionStore, swappable for real auth - dashboardService: getStats(organizationId?) Enhancements: - types.ts: typed ServiceErrorCode + ServiceError, Pagination alias - aiService.ts: openRouterAIService stub (throws ai_generation_failed) - lib/mockUtils.ts: mockDelay(ms) utility New hooks: - useUnifiedResults(needId?): joins matches+properties+signals → UnifiedMatchResult[] - useReviewQueue: + useApproveReviewItem + useRejectReviewItem - useMatchDetail, usePropertyDetail, useNeedProfiles aliases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,5 @@ export * from './futureSignal'
|
|||||||
export * from './aiOutput'
|
export * from './aiOutput'
|
||||||
export * from './activityEvent'
|
export * from './activityEvent'
|
||||||
export * from './unifiedResult'
|
export * from './unifiedResult'
|
||||||
|
export * from './shortlist'
|
||||||
|
export * from './review'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const ReviewPriority = {
|
||||||
|
HIGH: 'HIGH',
|
||||||
|
MEDIUM: 'MEDIUM',
|
||||||
|
LOW: 'LOW',
|
||||||
|
} as const
|
||||||
|
export type ReviewPriority = typeof ReviewPriority[keyof typeof ReviewPriority]
|
||||||
|
|
||||||
|
export const ReviewQueueStatus = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
IN_REVIEW: 'IN_REVIEW',
|
||||||
|
COMPLETED: 'COMPLETED',
|
||||||
|
} as const
|
||||||
|
export type ReviewQueueStatus = typeof ReviewQueueStatus[keyof typeof ReviewQueueStatus]
|
||||||
|
|
||||||
|
export interface ReviewQueueItem {
|
||||||
|
id: string
|
||||||
|
matchId: string
|
||||||
|
needId: string
|
||||||
|
propertyId: string
|
||||||
|
matchScore: number
|
||||||
|
priority: ReviewPriority
|
||||||
|
status: ReviewQueueStatus
|
||||||
|
assignedTo?: string
|
||||||
|
notes?: string
|
||||||
|
dueAt?: string
|
||||||
|
organizationId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ShortlistStatus } from './enums'
|
||||||
|
|
||||||
|
export interface ShortlistItem {
|
||||||
|
propertyId: string
|
||||||
|
addedAt: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shortlist {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
needId?: string
|
||||||
|
items: ShortlistItem[]
|
||||||
|
status: ShortlistStatus
|
||||||
|
createdBy: string
|
||||||
|
organizationId?: string
|
||||||
|
sharedWith?: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateShortlistInput = Omit<Shortlist, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
export type UpdateShortlistInput = Partial<CreateShortlistInput>
|
||||||
+5
-3
@@ -1,4 +1,6 @@
|
|||||||
export { useProperties, useProperty } from './useProperties'
|
export { useProperties, useProperty, usePropertyDetail } from './useProperties'
|
||||||
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch } from './useMatches'
|
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch, useMatchDetail } from './useMatches'
|
||||||
export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals'
|
export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals'
|
||||||
export { useNeeds, useNeed } from './useNeeds'
|
export { useNeeds, useNeed, useNeedProfiles } from './useNeeds'
|
||||||
|
export { useUnifiedResults } from './useUnifiedResults'
|
||||||
|
export { useReviewQueue, useApproveReviewItem, useRejectReviewItem } from './useReviewQueue'
|
||||||
|
|||||||
@@ -40,3 +40,13 @@ export function useApproveMatch() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMatchDetail(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['match', id],
|
||||||
|
queryFn: () => matchService.getById(id),
|
||||||
|
staleTime: STALE_MATCHES,
|
||||||
|
enabled: Boolean(id),
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ export function useNeed(id: string) {
|
|||||||
select: (res) => res.data ?? null,
|
select: (res) => res.data ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useNeedProfiles = useNeeds
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ export function useProperty(id: string) {
|
|||||||
select: (res) => res.data ?? null,
|
select: (res) => res.data ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const usePropertyDetail = useProperty
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reviewService } from '../services/reviewService'
|
||||||
|
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||||
|
|
||||||
|
const STALE_REVIEW = 30_000
|
||||||
|
|
||||||
|
export function useReviewQueue(filters?: ReviewFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reviewQueue', filters ?? {}],
|
||||||
|
queryFn: () => reviewService.getQueue(filters),
|
||||||
|
staleTime: STALE_REVIEW,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveReviewItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||||
|
reviewService.approve(id, 'current-user', notes),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectReviewItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||||
|
reviewService.reject(id, 'current-user', notes),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useMatches, useMatchesByNeed } from './useMatches'
|
||||||
|
import { useProperties } from './useProperties'
|
||||||
|
import { useFutureSignals } from './useFutureSignals'
|
||||||
|
import type {
|
||||||
|
UnifiedMatchResult,
|
||||||
|
VerifiedPortfolioResult,
|
||||||
|
ExternalMarketResult,
|
||||||
|
FutureAvailabilityResult,
|
||||||
|
} from '../domain/unifiedResult'
|
||||||
|
|
||||||
|
export function useUnifiedResults(needId?: string) {
|
||||||
|
const allMatchesQuery = useMatches()
|
||||||
|
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
||||||
|
const propertiesQuery = useProperties()
|
||||||
|
const signalsQuery = useFutureSignals()
|
||||||
|
|
||||||
|
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
||||||
|
const error = matchesQuery.error ?? propertiesQuery.error ?? signalsQuery.error
|
||||||
|
|
||||||
|
const matches = matchesQuery.data ?? []
|
||||||
|
const properties = propertiesQuery.data ?? []
|
||||||
|
const signals = signalsQuery.data ?? []
|
||||||
|
|
||||||
|
const data = useMemo((): UnifiedMatchResult[] => {
|
||||||
|
return matches
|
||||||
|
.flatMap((match): UnifiedMatchResult[] => {
|
||||||
|
const rt = match.resultType ?? 'VERIFIED_PORTFOLIO'
|
||||||
|
|
||||||
|
if (rt === 'FUTURE_AVAILABILITY') {
|
||||||
|
const refId = match.resultId ?? match.propertyId
|
||||||
|
const signal = signals.find(
|
||||||
|
s => s.id === refId || s.propertyId === refId,
|
||||||
|
)
|
||||||
|
if (!signal) return []
|
||||||
|
const result: FutureAvailabilityResult = {
|
||||||
|
matchId: match.id,
|
||||||
|
needId: match.needId,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
resultType: 'FUTURE_AVAILABILITY',
|
||||||
|
signal,
|
||||||
|
match,
|
||||||
|
}
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
const property = properties.find(p => p.id === (match.resultId ?? match.propertyId))
|
||||||
|
if (!property) return []
|
||||||
|
|
||||||
|
if (rt === 'EXTERNAL_MARKET') {
|
||||||
|
const result: ExternalMarketResult = {
|
||||||
|
matchId: match.id,
|
||||||
|
needId: match.needId,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
resultType: 'EXTERNAL_MARKET',
|
||||||
|
property,
|
||||||
|
match,
|
||||||
|
}
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: VerifiedPortfolioResult = {
|
||||||
|
matchId: match.id,
|
||||||
|
needId: match.needId,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
|
property,
|
||||||
|
match,
|
||||||
|
}
|
||||||
|
return [result]
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
}, [matches, properties, signals])
|
||||||
|
|
||||||
|
return { data, isLoading, error }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const mockDelay = (ms = 150) => new Promise<void>(res => setTimeout(res, ms))
|
||||||
@@ -2,3 +2,5 @@ export { mockProperties } from './properties'
|
|||||||
export { mockNeeds } from './needs'
|
export { mockNeeds } from './needs'
|
||||||
export { mockMatches } from './matches'
|
export { mockMatches } from './matches'
|
||||||
export { mockFutureSignals } from './futureSignals'
|
export { mockFutureSignals } from './futureSignals'
|
||||||
|
export { mockShortlists } from './shortlists'
|
||||||
|
export { mockReviewQueue } from './reviewQueue'
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReviewQueueItem } from '../domain/review'
|
||||||
|
|
||||||
|
export const mockReviewQueue: ReviewQueueItem[] = [
|
||||||
|
{
|
||||||
|
id: 'review-001',
|
||||||
|
matchId: 'match-001',
|
||||||
|
needId: 'need-001',
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
matchScore: 88,
|
||||||
|
priority: 'HIGH',
|
||||||
|
status: 'PENDING',
|
||||||
|
assignedTo: 'user-001',
|
||||||
|
dueAt: '2025-06-15T17:00:00Z',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-002',
|
||||||
|
matchId: 'match-002',
|
||||||
|
needId: 'need-001',
|
||||||
|
propertyId: 'prop-004',
|
||||||
|
matchScore: 64,
|
||||||
|
priority: 'MEDIUM',
|
||||||
|
status: 'PENDING',
|
||||||
|
dueAt: '2025-06-20T17:00:00Z',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:15:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:15:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-003',
|
||||||
|
matchId: 'match-004',
|
||||||
|
needId: 'need-002',
|
||||||
|
propertyId: 'prop-006',
|
||||||
|
matchScore: 47,
|
||||||
|
priority: 'LOW',
|
||||||
|
status: 'PENDING',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-11T09:00:00Z',
|
||||||
|
updatedAt: '2025-05-11T09:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Shortlist } from '../domain/shortlist'
|
||||||
|
import { ShortlistStatus } from '../domain/enums'
|
||||||
|
|
||||||
|
export const mockShortlists: Shortlist[] = [
|
||||||
|
{
|
||||||
|
id: 'shortlist-001',
|
||||||
|
title: 'Top Büroflächen Zürich',
|
||||||
|
description: 'Beste Bürooptionen für Innovatech AG',
|
||||||
|
needId: 'need-001',
|
||||||
|
items: [
|
||||||
|
{ propertyId: 'prop-001', addedAt: '2025-03-01T10:00:00Z', note: 'Erste Wahl' },
|
||||||
|
{ propertyId: 'prop-004', addedAt: '2025-03-02T14:30:00Z', note: 'Interessante Alternative' },
|
||||||
|
],
|
||||||
|
status: ShortlistStatus.ACTIVE,
|
||||||
|
createdBy: 'user-001',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-03-01T10:00:00Z',
|
||||||
|
updatedAt: '2025-03-02T14:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shortlist-002',
|
||||||
|
title: 'Logistik Optionen Basel',
|
||||||
|
description: 'Auswahl für Schweizer Logistik GmbH',
|
||||||
|
needId: 'need-002',
|
||||||
|
items: [
|
||||||
|
{ propertyId: 'prop-002', addedAt: '2025-03-05T09:00:00Z', note: 'Perfekte Grösse' },
|
||||||
|
],
|
||||||
|
status: ShortlistStatus.SHARED,
|
||||||
|
createdBy: 'user-001',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
sharedWith: ['client@schweizer-logistik.ch'],
|
||||||
|
createdAt: '2025-03-05T09:00:00Z',
|
||||||
|
updatedAt: '2025-03-06T11:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface DashboardStats {
|
||||||
|
totalProperties: number
|
||||||
|
verifiedProperties: number
|
||||||
|
externalMarketProperties: number
|
||||||
|
futureSignalProperties: number
|
||||||
|
totalMatches: number
|
||||||
|
pendingReviews: number
|
||||||
|
approvedMatches: number
|
||||||
|
activeNeeds: number
|
||||||
|
totalSignals: number
|
||||||
|
verifiedSignals: number
|
||||||
|
averageMatchScore: number
|
||||||
|
highConfidenceMatches: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDashboardProvider {
|
||||||
|
getStats(organizationId?: string): Promise<DashboardStats>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReviewQueueItem, ReviewPriority, ReviewQueueStatus } from '../domain/review'
|
||||||
|
|
||||||
|
export interface ReviewFilters {
|
||||||
|
priority?: ReviewPriority
|
||||||
|
status?: ReviewQueueStatus
|
||||||
|
assignedTo?: string
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReviewProvider {
|
||||||
|
getQueue(filters?: ReviewFilters): Promise<ReviewQueueItem[]>
|
||||||
|
getById(id: string): Promise<ReviewQueueItem | null>
|
||||||
|
approve(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
|
||||||
|
reject(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
|
||||||
|
assign(id: string, assignTo: string): Promise<ReviewQueueItem>
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
||||||
|
import type { ShortlistStatus } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface ShortlistFilters {
|
||||||
|
needId?: string
|
||||||
|
status?: ShortlistStatus
|
||||||
|
createdBy?: string
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IShortlistProvider {
|
||||||
|
getAll(filters?: ShortlistFilters): Promise<Shortlist[]>
|
||||||
|
getById(id: string): Promise<Shortlist | null>
|
||||||
|
create(data: CreateShortlistInput): Promise<Shortlist>
|
||||||
|
update(id: string, data: UpdateShortlistInput): Promise<Shortlist>
|
||||||
|
addItem(id: string, propertyId: string, note?: string): Promise<Shortlist>
|
||||||
|
removeItem(id: string, propertyId: string): Promise<Shortlist>
|
||||||
|
remove(id: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { IDashboardProvider, DashboardStats } from './IDashboardProvider'
|
||||||
|
import { mockProperties } from '../mock-data/properties'
|
||||||
|
import { mockMatches } from '../mock-data/matches'
|
||||||
|
import { mockNeeds } from '../mock-data/needs'
|
||||||
|
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||||
|
import { mockReviewQueue } from '../mock-data/reviewQueue'
|
||||||
|
import { mockDelay } from '../lib/mockUtils'
|
||||||
|
|
||||||
|
export const MockupDashboardProvider: IDashboardProvider = {
|
||||||
|
async getStats(organizationId?) {
|
||||||
|
await mockDelay()
|
||||||
|
|
||||||
|
let props = mockProperties
|
||||||
|
let matches = mockMatches
|
||||||
|
let needs = mockNeeds
|
||||||
|
let signals = mockFutureSignals
|
||||||
|
let queue = mockReviewQueue
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
props = props.filter(p => p.organizationId === organizationId)
|
||||||
|
matches = matches.filter(m => m.organizationId === organizationId)
|
||||||
|
needs = needs.filter(n => n.organizationId === organizationId)
|
||||||
|
signals = signals.filter(s => s.organizationId === organizationId)
|
||||||
|
queue = queue.filter(r => r.organizationId === organizationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgScore =
|
||||||
|
matches.length > 0
|
||||||
|
? matches.reduce((sum, m) => sum + m.matchScore, 0) / matches.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const stats: DashboardStats = {
|
||||||
|
totalProperties: props.length,
|
||||||
|
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
||||||
|
externalMarketProperties: props.filter(p => p.resultType === 'EXTERNAL_MARKET').length,
|
||||||
|
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
|
||||||
|
totalMatches: matches.length,
|
||||||
|
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
|
||||||
|
approvedMatches: matches.filter(m => m.isApproved === true).length,
|
||||||
|
activeNeeds: needs.length,
|
||||||
|
totalSignals: signals.length,
|
||||||
|
verifiedSignals: signals.filter(s => s.isVerified).length,
|
||||||
|
averageMatchScore: Math.round(avgScore),
|
||||||
|
highConfidenceMatches: matches.filter(m => m.confidenceLevel >= 0.75).length,
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { IReviewProvider, ReviewFilters } from './IReviewProvider'
|
||||||
|
import type { ReviewQueueItem } from '../domain/review'
|
||||||
|
import { mockReviewQueue } from '../mock-data/reviewQueue'
|
||||||
|
import { mockDelay } from '../lib/mockUtils'
|
||||||
|
|
||||||
|
const store: ReviewQueueItem[] = [...mockReviewQueue]
|
||||||
|
|
||||||
|
const priorityOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
|
export const MockupReviewProvider: IReviewProvider = {
|
||||||
|
async getQueue(filters?: ReviewFilters) {
|
||||||
|
await mockDelay()
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.priority) results = results.filter(r => r.priority === filters.priority)
|
||||||
|
if (filters?.status) results = results.filter(r => r.status === filters.status)
|
||||||
|
if (filters?.assignedTo) results = results.filter(r => r.assignedTo === filters.assignedTo)
|
||||||
|
if (filters?.organizationId) results = results.filter(r => r.organizationId === filters.organizationId)
|
||||||
|
return results.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9))
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
await mockDelay()
|
||||||
|
return store.find(r => r.id === id) ?? null
|
||||||
|
},
|
||||||
|
async approve(id, _reviewedBy, notes?) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(r => r.id === id)
|
||||||
|
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async reject(id, _reviewedBy, notes?) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(r => r.id === id)
|
||||||
|
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async assign(id, assignTo) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(r => r.id === id)
|
||||||
|
store[idx] = { ...store[idx], assignedTo: assignTo, status: 'IN_REVIEW', updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { IShortlistProvider, ShortlistFilters } from './IShortlistProvider'
|
||||||
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
||||||
|
import { mockShortlists } from '../mock-data/shortlists'
|
||||||
|
import { mockDelay } from '../lib/mockUtils'
|
||||||
|
|
||||||
|
const store: Shortlist[] = [...mockShortlists]
|
||||||
|
|
||||||
|
export const MockupShortlistProvider: IShortlistProvider = {
|
||||||
|
async getAll(filters?: ShortlistFilters) {
|
||||||
|
await mockDelay()
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.needId) results = results.filter(s => s.needId === filters.needId)
|
||||||
|
if (filters?.status) results = results.filter(s => s.status === filters.status)
|
||||||
|
if (filters?.createdBy) results = results.filter(s => s.createdBy === filters.createdBy)
|
||||||
|
if (filters?.organizationId) results = results.filter(s => s.organizationId === filters.organizationId)
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
await mockDelay()
|
||||||
|
return store.find(s => s.id === id) ?? null
|
||||||
|
},
|
||||||
|
async create(data: CreateShortlistInput) {
|
||||||
|
await mockDelay()
|
||||||
|
const next: Shortlist = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...data,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
store.push(next)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
async update(id, data: UpdateShortlistInput) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(s => s.id === id)
|
||||||
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async addItem(id, propertyId, note?) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(s => s.id === id)
|
||||||
|
const alreadyAdded = store[idx].items.some(i => i.propertyId === propertyId)
|
||||||
|
if (!alreadyAdded) {
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
items: [...store[idx].items, { propertyId, addedAt: new Date().toISOString(), note }],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async removeItem(id, propertyId) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(s => s.id === id)
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
items: store[idx].items.filter(i => i.propertyId !== propertyId),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
await mockDelay()
|
||||||
|
const idx = store.findIndex(s => s.id === id)
|
||||||
|
store.splice(idx, 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ItemResponse } from './types'
|
import type { ItemResponse, ServiceError } from './types'
|
||||||
|
import { ServiceErrorCode } from './types'
|
||||||
import type { CreateNeedInput } from '../domain/need'
|
import type { CreateNeedInput } from '../domain/need'
|
||||||
|
|
||||||
export interface CriteriaExtractionResult {
|
export interface CriteriaExtractionResult {
|
||||||
@@ -45,6 +46,21 @@ const MockupAIServiceProvider: AIServiceProvider = {
|
|||||||
|
|
||||||
const provider = MockupAIServiceProvider
|
const provider = MockupAIServiceProvider
|
||||||
|
|
||||||
|
const notConfiguredError = (): ServiceError => ({
|
||||||
|
code: ServiceErrorCode.AI_GENERATION_FAILED,
|
||||||
|
message: 'OpenRouter nicht konfiguriert',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stub — swap for real OpenRouter implementation without changing call sites
|
||||||
|
export const openRouterAIService: AIServiceProvider = {
|
||||||
|
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
||||||
|
throw notConfiguredError()
|
||||||
|
},
|
||||||
|
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||||
|
throw notConfiguredError()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const aiService = {
|
export const aiService = {
|
||||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||||
const data = await provider.extractCriteria(input)
|
const data = await provider.extractCriteria(input)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
import type { MockUser } from '../stores/sessionStore'
|
||||||
|
import type { ItemResponse } from './types'
|
||||||
|
import { UserRole } from '../domain/enums'
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
|
||||||
|
const data = useSessionStore.getState().currentUser
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async login(email: string, _password: string): Promise<ItemResponse<MockUser>> {
|
||||||
|
const user: MockUser = {
|
||||||
|
id: 'user-001',
|
||||||
|
email,
|
||||||
|
name: 'Admin User',
|
||||||
|
role: UserRole.ORGANIZATION_ADMIN,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
organizationName: 'Wincasa AG',
|
||||||
|
}
|
||||||
|
useSessionStore.getState().login(user)
|
||||||
|
return { data: user }
|
||||||
|
},
|
||||||
|
async logout(): Promise<ItemResponse<void>> {
|
||||||
|
useSessionStore.getState().logout()
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
async isAuthenticated(): Promise<ItemResponse<boolean>> {
|
||||||
|
const data = useSessionStore.getState().isAuthenticated
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { MockupDashboardProvider } from '../provider/MockupDashboardProvider'
|
||||||
|
import type { DashboardStats } from '../provider/IDashboardProvider'
|
||||||
|
import type { ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupDashboardProvider
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
async getStats(organizationId?: string): Promise<ItemResponse<DashboardStats>> {
|
||||||
|
const data = await provider.getStats(organizationId)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
|
||||||
|
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||||
|
import type { ReviewQueueItem } from '../domain/review'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupReviewProvider
|
||||||
|
|
||||||
|
export const reviewService = {
|
||||||
|
async getQueue(filters?: ReviewFilters): Promise<ListResponse<ReviewQueueItem>> {
|
||||||
|
const data = await provider.getQueue(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<ReviewQueueItem | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async approve(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||||
|
const data = await provider.approve(id, reviewedBy, notes)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async reject(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||||
|
const data = await provider.reject(id, reviewedBy, notes)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async assign(id: string, assignTo: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||||
|
const data = await provider.assign(id, assignTo)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
|
||||||
|
import type { ShortlistFilters } from '../provider/IShortlistProvider'
|
||||||
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupShortlistProvider
|
||||||
|
|
||||||
|
export const shortlistService = {
|
||||||
|
async getAll(filters?: ShortlistFilters): Promise<ListResponse<Shortlist>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<Shortlist | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async create(input: CreateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
||||||
|
const data = await provider.create(input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async update(id: string, input: UpdateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
||||||
|
const data = await provider.update(id, input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async addItem(id: string, propertyId: string, note?: string): Promise<ItemResponse<Shortlist>> {
|
||||||
|
const data = await provider.addItem(id, propertyId, note)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async removeItem(id: string, propertyId: string): Promise<ItemResponse<Shortlist>> {
|
||||||
|
const data = await provider.removeItem(id, propertyId)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
await provider.remove(id)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
}
|
||||||
+30
-3
@@ -1,14 +1,41 @@
|
|||||||
export interface ServiceMeta {
|
// ── Error Codes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ServiceErrorCode = {
|
||||||
|
NETWORK_ERROR: 'network_error',
|
||||||
|
UNAUTHORIZED: 'unauthorized',
|
||||||
|
FORBIDDEN: 'forbidden',
|
||||||
|
VALIDATION_ERROR: 'validation_error',
|
||||||
|
NOT_FOUND: 'not_found',
|
||||||
|
AI_GENERATION_FAILED: 'ai_generation_failed',
|
||||||
|
BACKEND_UNAVAILABLE: 'backend_unavailable',
|
||||||
|
} as const
|
||||||
|
export type ServiceErrorCode = typeof ServiceErrorCode[keyof typeof ServiceErrorCode]
|
||||||
|
|
||||||
|
export interface ServiceError {
|
||||||
|
code: ServiceErrorCode
|
||||||
|
message: string
|
||||||
|
details?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pagination ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Pagination {
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use Pagination */
|
||||||
|
export type ServiceMeta = Pagination
|
||||||
|
|
||||||
|
// ── Response Shapes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ServiceResponse<T> {
|
export interface ServiceResponse<T> {
|
||||||
data: T
|
data: T
|
||||||
meta?: ServiceMeta
|
meta?: Pagination
|
||||||
error?: string | null
|
pagination?: Pagination
|
||||||
|
error?: ServiceError | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResponse<T> = ServiceResponse<T[]>
|
export type ListResponse<T> = ServiceResponse<T[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user