@nexxtmove/ui 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +75 -0
- package/dist/index.js +1910 -1130
- package/dist/nuxt.js +20 -17
- package/package.json +2 -1
- package/src/components/Calendar/Calendar.test.ts +54 -44
- package/src/components/Calendar/Calendar.vue +31 -13
- package/src/components/Calendar/_CalendarDayView.test.ts +48 -89
- package/src/components/Calendar/_CalendarDayView.vue +19 -6
- package/src/components/Calendar/_CalendarMonthView.test.ts +24 -40
- package/src/components/Calendar/_CalendarMonthView.vue +17 -8
- package/src/components/Calendar/_CalendarYearView.test.ts +56 -0
- package/src/components/Calendar/_CalendarYearView.vue +8 -2
- package/src/components/Calendar/calendar.types.ts +1 -0
- package/src/components/DatePicker/DatePicker.vue +9 -6
- package/src/components/TimelineEvent/TimelineEvent.stories.ts +291 -0
- package/src/components/TimelineEvent/TimelineEvent.test.ts +166 -0
- package/src/components/TimelineEvent/TimelineEvent.vue +111 -0
- package/src/components/TimelineEvent/TimelineTypes.ts +236 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.vue +154 -0
- package/src/components/Tooltip/Tooltip.stories.ts +146 -0
- package/src/components/Tooltip/Tooltip.test.ts +122 -0
- package/src/components/Tooltip/Tooltip.vue +45 -0
- package/src/components.json +4 -1
- package/src/index.ts +3 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import TimelineEvent from './TimelineEvent.vue'
|
|
4
|
+
|
|
5
|
+
const baseEvent = {
|
|
6
|
+
id: 1,
|
|
7
|
+
happened_at: '2024-06-15T10:30:00.000Z',
|
|
8
|
+
medium_type: null,
|
|
9
|
+
url: null,
|
|
10
|
+
page_title: null,
|
|
11
|
+
request_reason: null,
|
|
12
|
+
agenda_item_type: null,
|
|
13
|
+
source: 'system',
|
|
14
|
+
direction: 'outbound',
|
|
15
|
+
description: null,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const mountEvent = (eventOverrides = {}, props = {}) =>
|
|
19
|
+
mount(TimelineEvent, {
|
|
20
|
+
props: {
|
|
21
|
+
event: { ...baseEvent, type: 'contact_created', ...eventOverrides },
|
|
22
|
+
...props,
|
|
23
|
+
},
|
|
24
|
+
global: {
|
|
25
|
+
stubs: {
|
|
26
|
+
NexxtIcon: true,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('TimelineEvent', () => {
|
|
32
|
+
it('renders the formatted date', () => {
|
|
33
|
+
const wrapper = mountEvent()
|
|
34
|
+
const dateText = wrapper.find('.extra-small-normal').text()
|
|
35
|
+
expect(dateText).toContain('om')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('sets data-event-source attribute from direction', () => {
|
|
39
|
+
const wrapper = mountEvent({ direction: 'inbound' })
|
|
40
|
+
expect(wrapper.attributes('data-event-source')).toBe('inbound')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('sets data-event-date attribute from happened_at', () => {
|
|
44
|
+
const wrapper = mountEvent({ happened_at: '2024-06-15T10:30:00.000Z' })
|
|
45
|
+
expect(wrapper.attributes('data-event-date')).toBe('2024-06-15T10:30:00.000Z')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('sets date-event-type attribute from type', () => {
|
|
49
|
+
const wrapper = mountEvent({ type: 'page_viewed' })
|
|
50
|
+
expect(wrapper.attributes('date-event-type')).toBe('page_viewed')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders the event label for a known event type', () => {
|
|
54
|
+
const wrapper = mountEvent({ type: 'contact_created' })
|
|
55
|
+
expect(wrapper.text()).toContain('Contact aangemaakt')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders the event type as label for an unknown event type', () => {
|
|
59
|
+
const wrapper = mountEvent({ type: 'custom_unknown_type' })
|
|
60
|
+
expect(wrapper.text()).toContain('custom_unknown_type')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not render a link when event.url is null', () => {
|
|
64
|
+
const wrapper = mountEvent({ url: null })
|
|
65
|
+
expect(wrapper.find('a').exists()).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders a link when event.url is set', () => {
|
|
69
|
+
const wrapper = mountEvent({ url: 'https://example.com' })
|
|
70
|
+
const link = wrapper.find('a')
|
|
71
|
+
expect(link.exists()).toBe(true)
|
|
72
|
+
expect(link.attributes('href')).toBe('https://example.com')
|
|
73
|
+
expect(link.attributes('target')).toBe('_blank')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders event.description as link text when set', () => {
|
|
77
|
+
const wrapper = mountEvent({ url: 'https://example.com', description: 'Mijn voorstel' })
|
|
78
|
+
expect(wrapper.find('a').text()).toContain('Mijn voorstel')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('renders event.url as link text when description is null', () => {
|
|
82
|
+
const wrapper = mountEvent({ url: 'https://example.com', description: null })
|
|
83
|
+
expect(wrapper.find('a').text()).toContain('https://example.com')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not render request_reason when not set', () => {
|
|
87
|
+
const wrapper = mountEvent({ request_reason: null })
|
|
88
|
+
expect(wrapper.text()).not.toContain('Reden:')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders request_reason when set', () => {
|
|
92
|
+
const wrapper = mountEvent({ request_reason: 'Interesse in de woning' })
|
|
93
|
+
expect(wrapper.text()).toContain('Reden: Interesse in de woning')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('aligns outbound events to the left by default (no justify-end)', () => {
|
|
97
|
+
const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
|
|
98
|
+
expect(wrapper.classes()).not.toContain('justify-end')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('aligns inbound events to the right when splitView is true', () => {
|
|
102
|
+
const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
|
|
103
|
+
expect(wrapper.classes()).toContain('justify-end')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not align inbound events to the right when splitView is false', () => {
|
|
107
|
+
const wrapper = mountEvent({ direction: 'inbound' }, { splitView: false })
|
|
108
|
+
expect(wrapper.classes()).not.toContain('justify-end')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('reverses icon and content order for inbound events in splitView', () => {
|
|
112
|
+
const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
|
|
113
|
+
const innerRow = wrapper.find('.flex.w-full.shrink-0')
|
|
114
|
+
expect(innerRow.classes()).toContain('flex-row-reverse')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not reverse order for outbound events', () => {
|
|
118
|
+
const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
|
|
119
|
+
const innerRow = wrapper.find('.flex.w-full.shrink-0')
|
|
120
|
+
expect(innerRow.classes()).not.toContain('flex-row-reverse')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('applies max-w-lg to content when splitView is true', () => {
|
|
124
|
+
const wrapper = mountEvent({}, { splitView: true })
|
|
125
|
+
const content = wrapper.find('.rounded-xl')
|
|
126
|
+
expect(content.classes()).toContain('max-w-lg')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does not apply max-w-lg to content when splitView is false', () => {
|
|
130
|
+
const wrapper = mountEvent({}, { splitView: false })
|
|
131
|
+
const content = wrapper.find('.rounded-xl')
|
|
132
|
+
expect(content.classes()).not.toContain('max-w-lg')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('defaults splitView to true', () => {
|
|
136
|
+
const wrapper = mount(TimelineEvent, {
|
|
137
|
+
props: {
|
|
138
|
+
event: { ...baseEvent, type: 'contact_created', direction: 'inbound' },
|
|
139
|
+
},
|
|
140
|
+
global: { stubs: { NexxtIcon: true } },
|
|
141
|
+
})
|
|
142
|
+
expect(wrapper.classes()).toContain('justify-end')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('applies correct background color class for known event type', () => {
|
|
146
|
+
const wrapper = mountEvent({ type: 'contact_created' })
|
|
147
|
+
const iconWrapper = wrapper.find('.rounded-full')
|
|
148
|
+
expect(iconWrapper.classes()).toContain('bg-pink-500')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('applies default background color for unknown event type', () => {
|
|
152
|
+
const wrapper = mountEvent({ type: 'unknown_type' })
|
|
153
|
+
const iconWrapper = wrapper.find('.rounded-full')
|
|
154
|
+
expect(iconWrapper.classes()).toContain('bg-gray-500')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('aligns date text to the right for inbound events in splitView', () => {
|
|
158
|
+
const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
|
|
159
|
+
expect(wrapper.find('.extra-small-normal').classes()).toContain('text-right')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('does not align date text to the right for outbound events', () => {
|
|
163
|
+
const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
|
|
164
|
+
expect(wrapper.find('.extra-small-normal').classes()).not.toContain('text-right')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import NexxtIcon from '../Icon/Icon.vue'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import { EVENT_CONFIG_MAP, type TimelineEvent as Event } from './TimelineTypes'
|
|
5
|
+
|
|
6
|
+
interface EventConfig {
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
color: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultEventConfig: EventConfig = {
|
|
13
|
+
label: 'Gebeurtenis',
|
|
14
|
+
icon: 'info',
|
|
15
|
+
color: 'bg-gray-500',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const eventConfig = computed(() => {
|
|
19
|
+
return EVENT_CONFIG_MAP[props.event.type] || { ...defaultEventConfig, label: props.event.type }
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
event: Event
|
|
25
|
+
splitView?: boolean
|
|
26
|
+
}>(),
|
|
27
|
+
{
|
|
28
|
+
splitView: true,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const formattedDate = computed(() => {
|
|
33
|
+
const date = new Date(props.event.happened_at)
|
|
34
|
+
const datePart = date.toLocaleDateString('nl-NL', {
|
|
35
|
+
day: 'numeric',
|
|
36
|
+
month: 'short',
|
|
37
|
+
year: 'numeric',
|
|
38
|
+
})
|
|
39
|
+
const timePart = date.toLocaleTimeString('nl-NL', {
|
|
40
|
+
hour: '2-digit',
|
|
41
|
+
minute: '2-digit',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return `${datePart} om ${timePart}`
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const isContactEvent = computed(() => {
|
|
48
|
+
return props.splitView && props.event.direction === 'inbound'
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div
|
|
54
|
+
:data-event-source="event.direction"
|
|
55
|
+
:data-event-date="event.happened_at"
|
|
56
|
+
:date-event-type="event.type"
|
|
57
|
+
class="relative z-20 flex flex-wrap items-center gap-x-6 gap-y-2"
|
|
58
|
+
:class="{
|
|
59
|
+
'justify-end': isContactEvent,
|
|
60
|
+
}"
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
class="w-full px-24 extra-small-normal text-gray-600"
|
|
64
|
+
:class="{
|
|
65
|
+
'text-right': isContactEvent,
|
|
66
|
+
}"
|
|
67
|
+
>
|
|
68
|
+
{{ formattedDate }}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
class="flex w-full shrink-0 items-center gap-6"
|
|
73
|
+
:class="{
|
|
74
|
+
'flex-row-reverse': isContactEvent,
|
|
75
|
+
}"
|
|
76
|
+
>
|
|
77
|
+
<div
|
|
78
|
+
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
|
|
79
|
+
:class="[eventConfig.color]"
|
|
80
|
+
>
|
|
81
|
+
<NexxtIcon
|
|
82
|
+
:name="eventConfig.icon as any"
|
|
83
|
+
:type="eventConfig.icon === 'whatsapp' ? 'brands' : 'light'"
|
|
84
|
+
class="flex h-6 w-6 text-xl text-white"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div
|
|
88
|
+
class="flex w-full flex-col gap-2 rounded-xl border border-gray-200 p-8"
|
|
89
|
+
:class="{ 'max-w-lg': splitView }"
|
|
90
|
+
>
|
|
91
|
+
<span class="heading-3">
|
|
92
|
+
{{ eventConfig.label }}
|
|
93
|
+
</span>
|
|
94
|
+
<a
|
|
95
|
+
v-if="event.url"
|
|
96
|
+
:href="event.url"
|
|
97
|
+
target="_blank"
|
|
98
|
+
class="group mt-1 flex justify-between gap-2 text-sm text-cornflower-blue-500 hover:text-cornflower-blue-600"
|
|
99
|
+
>
|
|
100
|
+
<span class="truncate group-hover:underline">
|
|
101
|
+
{{ event.description || event.url }}
|
|
102
|
+
</span>
|
|
103
|
+
<NexxtIcon name="external-link" class="ml-1 h-4 w-4 text-gray-950" />
|
|
104
|
+
</a>
|
|
105
|
+
<span v-if="event.request_reason" class="mt-1 text-sm text-gray-500">
|
|
106
|
+
Reden: {{ event.request_reason }}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export interface TimelineEvent {
|
|
2
|
+
id: number
|
|
3
|
+
type: string
|
|
4
|
+
happened_at: string
|
|
5
|
+
medium_type: string | null
|
|
6
|
+
url: string | null
|
|
7
|
+
page_title: string | null
|
|
8
|
+
request_reason: string | null
|
|
9
|
+
agenda_item_type: string | null
|
|
10
|
+
source: string
|
|
11
|
+
direction: string
|
|
12
|
+
description: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimelinePhase {
|
|
16
|
+
phase: {
|
|
17
|
+
value: number
|
|
18
|
+
}
|
|
19
|
+
entered_at: string | null
|
|
20
|
+
exited_at: string | null
|
|
21
|
+
events: TimelineEvent[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TimelineResponse {
|
|
25
|
+
data: {
|
|
26
|
+
current_phase: {
|
|
27
|
+
value: number
|
|
28
|
+
entered_at?: string
|
|
29
|
+
}
|
|
30
|
+
phases: TimelinePhase[]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PhaseConfig {
|
|
35
|
+
label: string
|
|
36
|
+
color: string
|
|
37
|
+
bgColor: string
|
|
38
|
+
fromColor: string
|
|
39
|
+
toColor: string
|
|
40
|
+
textColor: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const EVENT_CONFIG_MAP: Record<string, { label: string; icon: string; color: string }> = {
|
|
44
|
+
// FieldResearch / Awareness
|
|
45
|
+
contact_created: { label: 'Contact aangemaakt', icon: 'user-plus', color: 'bg-pink-500' },
|
|
46
|
+
page_viewed: { label: 'Pagina bekeken', icon: 'eye', color: 'bg-blue-500' },
|
|
47
|
+
house_viewing_requested: {
|
|
48
|
+
label: 'Bezichtiging aangevraagd',
|
|
49
|
+
icon: 'house-chimney',
|
|
50
|
+
color: 'bg-orange-500',
|
|
51
|
+
},
|
|
52
|
+
automation_phase_opened: {
|
|
53
|
+
label: 'Automation e-mail geopend',
|
|
54
|
+
icon: 'envelope-open-text',
|
|
55
|
+
color: 'bg-indigo-500',
|
|
56
|
+
},
|
|
57
|
+
automation_whatsapp_button_clicked: {
|
|
58
|
+
label: 'WhatsApp knop geklikt',
|
|
59
|
+
icon: 'whatsapp',
|
|
60
|
+
color: 'bg-[#25D366]',
|
|
61
|
+
},
|
|
62
|
+
automation_button_mail_answered: {
|
|
63
|
+
label: 'Automation mail beantwoord',
|
|
64
|
+
icon: 'reply',
|
|
65
|
+
color: 'bg-indigo-600',
|
|
66
|
+
},
|
|
67
|
+
waiting_list_signup: {
|
|
68
|
+
label: 'Aangemeld voor wachtlijst',
|
|
69
|
+
icon: 'list-check',
|
|
70
|
+
color: 'bg-yellow-500',
|
|
71
|
+
},
|
|
72
|
+
viewing_created: {
|
|
73
|
+
label: 'Bezichtiging aangemaakt',
|
|
74
|
+
icon: 'house',
|
|
75
|
+
color: 'bg-orange-400',
|
|
76
|
+
},
|
|
77
|
+
viewing_agenda_item_scheduled: {
|
|
78
|
+
label: 'Bezichtiging gepland',
|
|
79
|
+
icon: 'calendar-check',
|
|
80
|
+
color: 'bg-orange-400',
|
|
81
|
+
},
|
|
82
|
+
general_agenda_item_scheduled: {
|
|
83
|
+
label: 'Afspraak gepland',
|
|
84
|
+
icon: 'calendar',
|
|
85
|
+
color: 'bg-gray-400',
|
|
86
|
+
},
|
|
87
|
+
sales_agenda_item_scheduled: {
|
|
88
|
+
label: 'Verkoopgesprek gepland',
|
|
89
|
+
icon: 'handshake',
|
|
90
|
+
color: 'bg-blue-500',
|
|
91
|
+
},
|
|
92
|
+
agenda_item_scheduled: {
|
|
93
|
+
label: 'Agenda-item gepland',
|
|
94
|
+
icon: 'calendar-plus',
|
|
95
|
+
color: 'bg-gray-500',
|
|
96
|
+
},
|
|
97
|
+
whatsapp_message_sent: {
|
|
98
|
+
label: 'WhatsApp-bericht verzonden',
|
|
99
|
+
icon: 'paper-plane',
|
|
100
|
+
color: 'bg-[#25D366]',
|
|
101
|
+
},
|
|
102
|
+
whatsapp_message_received: {
|
|
103
|
+
label: 'WhatsApp-bericht ontvangen',
|
|
104
|
+
icon: 'whatsapp',
|
|
105
|
+
color: 'bg-[#25D366]',
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Transaction
|
|
109
|
+
appraisal_agenda_item_scheduled: {
|
|
110
|
+
label: 'Taxatie gepland',
|
|
111
|
+
icon: 'file-invoice-dollar',
|
|
112
|
+
color: 'bg-teal-500',
|
|
113
|
+
},
|
|
114
|
+
purchase_agenda_item_scheduled: {
|
|
115
|
+
label: 'Aankoopgesprek gepland',
|
|
116
|
+
icon: 'cart-shopping',
|
|
117
|
+
color: 'bg-emerald-600',
|
|
118
|
+
},
|
|
119
|
+
structural_inspection_agenda_item_scheduled: {
|
|
120
|
+
label: 'Bouwkundige keuring gepland',
|
|
121
|
+
icon: 'hard-hat',
|
|
122
|
+
color: 'bg-amber-600',
|
|
123
|
+
},
|
|
124
|
+
valuation_agenda_item_scheduled: {
|
|
125
|
+
label: 'Waardebepaling gepland',
|
|
126
|
+
icon: 'calculator',
|
|
127
|
+
color: 'bg-teal-600',
|
|
128
|
+
},
|
|
129
|
+
mortgage_agenda_item_scheduled: {
|
|
130
|
+
label: 'Hypotheekgesprek gepland',
|
|
131
|
+
icon: 'money-check-dollar-pen',
|
|
132
|
+
color: 'bg-blue-600',
|
|
133
|
+
},
|
|
134
|
+
purchase_deed_signing_agenda_item_scheduled: {
|
|
135
|
+
label: 'Koopovereenkomst tekenen',
|
|
136
|
+
icon: 'pen-fancy',
|
|
137
|
+
color: 'bg-emerald-500',
|
|
138
|
+
},
|
|
139
|
+
proposal_sent: { label: 'Voorstel verzonden', icon: 'paper-plane-top', color: 'bg-blue-400' },
|
|
140
|
+
proposal_opened: { label: 'Voorstel geopend', icon: 'envelope-open', color: 'bg-blue-300' },
|
|
141
|
+
proposal_accepted: { label: 'Voorstel geaccepteerd', icon: 'check', color: 'bg-emerald-500' },
|
|
142
|
+
|
|
143
|
+
// Aftercare
|
|
144
|
+
final_inspection_agenda_item_scheduled: {
|
|
145
|
+
label: 'Eindinspectie gepland',
|
|
146
|
+
icon: 'clipboard-check',
|
|
147
|
+
color: 'bg-green-400',
|
|
148
|
+
},
|
|
149
|
+
key_handover_agenda_item_scheduled: {
|
|
150
|
+
label: 'Sleuteloverdracht gepland',
|
|
151
|
+
icon: 'key',
|
|
152
|
+
color: 'bg-green-500',
|
|
153
|
+
},
|
|
154
|
+
transport_agenda_item_scheduled: {
|
|
155
|
+
label: 'Transport bij notaris',
|
|
156
|
+
icon: 'file-signature',
|
|
157
|
+
color: 'bg-green-600',
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// Other / Awareness
|
|
161
|
+
newsletter_opened: {
|
|
162
|
+
label: 'Nieuwsbrief geopend',
|
|
163
|
+
icon: 'envelope-open',
|
|
164
|
+
color: 'bg-indigo-400',
|
|
165
|
+
},
|
|
166
|
+
contact_requested: { label: 'Contactaanvraag', icon: 'user', color: 'bg-indigo-500' },
|
|
167
|
+
house_valuation_requested: {
|
|
168
|
+
label: 'Waardebepaling aangevraagd',
|
|
169
|
+
icon: 'search',
|
|
170
|
+
color: 'bg-teal-500',
|
|
171
|
+
},
|
|
172
|
+
unsubscribed: {
|
|
173
|
+
label: 'Afgemeld',
|
|
174
|
+
icon: 'user-slash',
|
|
175
|
+
color: 'bg-red-500',
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const PHASE_MAP: Record<number, PhaseConfig> = {
|
|
180
|
+
0: {
|
|
181
|
+
label: 'Inactief',
|
|
182
|
+
color: 'border-slate-300',
|
|
183
|
+
bgColor: 'bg-slate-300',
|
|
184
|
+
fromColor: 'from-slate-300',
|
|
185
|
+
toColor: 'to-slate-300',
|
|
186
|
+
textColor: 'text-gray-700',
|
|
187
|
+
},
|
|
188
|
+
1: {
|
|
189
|
+
label: 'Latente woonfase',
|
|
190
|
+
color: 'border-amber-500',
|
|
191
|
+
bgColor: 'bg-amber-500',
|
|
192
|
+
fromColor: 'from-amber-500',
|
|
193
|
+
toColor: 'to-amber-500',
|
|
194
|
+
textColor: 'text-white',
|
|
195
|
+
},
|
|
196
|
+
2: {
|
|
197
|
+
label: 'Oriëntatie',
|
|
198
|
+
color: 'border-purple-500',
|
|
199
|
+
bgColor: 'bg-purple-500',
|
|
200
|
+
fromColor: 'from-purple-500',
|
|
201
|
+
toColor: 'to-purple-500',
|
|
202
|
+
textColor: 'text-white',
|
|
203
|
+
},
|
|
204
|
+
3: {
|
|
205
|
+
label: 'Actieve zoektocht',
|
|
206
|
+
color: 'border-yellow-500',
|
|
207
|
+
bgColor: 'bg-yellow-500',
|
|
208
|
+
fromColor: 'from-yellow-500',
|
|
209
|
+
toColor: 'to-yellow-500',
|
|
210
|
+
textColor: 'text-white',
|
|
211
|
+
},
|
|
212
|
+
4: {
|
|
213
|
+
label: 'Selectie',
|
|
214
|
+
color: 'border-blue-500',
|
|
215
|
+
bgColor: 'bg-blue-500',
|
|
216
|
+
fromColor: 'from-blue-500',
|
|
217
|
+
toColor: 'to-blue-500',
|
|
218
|
+
textColor: 'text-white',
|
|
219
|
+
},
|
|
220
|
+
5: {
|
|
221
|
+
label: 'Transactie',
|
|
222
|
+
color: 'border-emerald-500',
|
|
223
|
+
bgColor: 'bg-emerald-500',
|
|
224
|
+
fromColor: 'from-emerald-500',
|
|
225
|
+
toColor: 'to-emerald-500',
|
|
226
|
+
textColor: 'text-white',
|
|
227
|
+
},
|
|
228
|
+
6: {
|
|
229
|
+
label: 'Wonen',
|
|
230
|
+
color: 'border-green-500',
|
|
231
|
+
bgColor: 'bg-green-500',
|
|
232
|
+
fromColor: 'from-green-500',
|
|
233
|
+
toColor: 'to-green-500',
|
|
234
|
+
textColor: 'text-white',
|
|
235
|
+
},
|
|
236
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import TimelinePhaseblock from './TimelinePhaseblock.vue'
|
|
3
|
+
import type { TimelinePhase } from '../TimelineEvent/TimelineTypes'
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: 'Components/Molecules/Timeline Phaseblock',
|
|
7
|
+
component: TimelinePhaseblock,
|
|
8
|
+
} satisfies Meta<typeof TimelinePhaseblock>
|
|
9
|
+
|
|
10
|
+
type Story = StoryObj<typeof TimelinePhaseblock>
|
|
11
|
+
|
|
12
|
+
const makePhase = (
|
|
13
|
+
value: number,
|
|
14
|
+
entered_at: string | null = '2024-06-01T09:00:00.000Z',
|
|
15
|
+
exited_at: string | null = '2024-07-01T09:00:00.000Z',
|
|
16
|
+
events: TimelinePhase['events'] = [],
|
|
17
|
+
): TimelinePhase => ({
|
|
18
|
+
phase: { value },
|
|
19
|
+
entered_at,
|
|
20
|
+
exited_at,
|
|
21
|
+
events,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const sampleEvent = {
|
|
25
|
+
id: 1,
|
|
26
|
+
type: 'contact_created',
|
|
27
|
+
happened_at: '2024-06-10T10:30:00.000Z',
|
|
28
|
+
medium_type: null,
|
|
29
|
+
url: null,
|
|
30
|
+
page_title: null,
|
|
31
|
+
request_reason: null,
|
|
32
|
+
agenda_item_type: null,
|
|
33
|
+
source: 'system',
|
|
34
|
+
direction: 'outbound',
|
|
35
|
+
description: null,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Single phase at the end of the timeline (no separator shown)
|
|
39
|
+
export const SinglePhase: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
phase: makePhase(1),
|
|
42
|
+
index: 0,
|
|
43
|
+
phases: [makePhase(1)],
|
|
44
|
+
isSplitView: true,
|
|
45
|
+
isLast: true,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// First phase (index 0) in split view — shows the Outbound/Inbound header
|
|
50
|
+
export const FirstPhaseWithHeader: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
phase: makePhase(4),
|
|
53
|
+
index: 0,
|
|
54
|
+
phases: [makePhase(4), makePhase(2)],
|
|
55
|
+
isSplitView: true,
|
|
56
|
+
isLast: false,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Phase separator visible — phase progressed upward (arrow up)
|
|
61
|
+
export const PhaseProgressedUp: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
phase: makePhase(4),
|
|
64
|
+
index: 0,
|
|
65
|
+
phases: [makePhase(4), makePhase(2)],
|
|
66
|
+
isSplitView: true,
|
|
67
|
+
isLast: false,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Phase separator visible — phase regressed downward (arrow down)
|
|
72
|
+
export const PhaseRegressedDown: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
phase: makePhase(2),
|
|
75
|
+
index: 0,
|
|
76
|
+
phases: [makePhase(2), makePhase(4)],
|
|
77
|
+
isSplitView: true,
|
|
78
|
+
isLast: false,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Last item in a multi-phase list — no separator shown
|
|
83
|
+
export const LastInList: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
phase: makePhase(1),
|
|
86
|
+
index: 1,
|
|
87
|
+
phases: [makePhase(4), makePhase(1)],
|
|
88
|
+
isSplitView: true,
|
|
89
|
+
isLast: true,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Non-split view — with phase separator
|
|
94
|
+
export const NonSplitView: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
phase: makePhase(3),
|
|
97
|
+
index: 0,
|
|
98
|
+
phases: [makePhase(3), makePhase(1)],
|
|
99
|
+
isSplitView: false,
|
|
100
|
+
isLast: false,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Non-split view — single/last phase (no separator)
|
|
105
|
+
export const NonSplitViewLast: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
phase: makePhase(3),
|
|
108
|
+
index: 0,
|
|
109
|
+
phases: [makePhase(3)],
|
|
110
|
+
isSplitView: false,
|
|
111
|
+
isLast: true,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Non-split view — with events
|
|
116
|
+
export const NonSplitViewWithEvents: Story = {
|
|
117
|
+
args: {
|
|
118
|
+
phase: makePhase(4, '2024-06-01T09:00:00.000Z', '2024-07-01T09:00:00.000Z', [
|
|
119
|
+
sampleEvent,
|
|
120
|
+
{ ...sampleEvent, id: 2, type: 'page_viewed', happened_at: '2024-06-12T14:00:00.000Z' },
|
|
121
|
+
]),
|
|
122
|
+
index: 0,
|
|
123
|
+
phases: [makePhase(4), makePhase(2)],
|
|
124
|
+
isSplitView: false,
|
|
125
|
+
isLast: false,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Phase with events
|
|
130
|
+
export const WithEvents: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
phase: makePhase(4, '2024-06-01T09:00:00.000Z', '2024-07-01T09:00:00.000Z', [
|
|
133
|
+
sampleEvent,
|
|
134
|
+
{ ...sampleEvent, id: 2, type: 'page_viewed', happened_at: '2024-06-12T14:00:00.000Z' },
|
|
135
|
+
{
|
|
136
|
+
...sampleEvent,
|
|
137
|
+
id: 3,
|
|
138
|
+
type: 'whatsapp_message_received',
|
|
139
|
+
direction: 'inbound',
|
|
140
|
+
happened_at: '2024-06-15T11:00:00.000Z',
|
|
141
|
+
},
|
|
142
|
+
]),
|
|
143
|
+
index: 0,
|
|
144
|
+
phases: [makePhase(4), makePhase(2)],
|
|
145
|
+
isSplitView: true,
|
|
146
|
+
isLast: false,
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// All phases shown together (newest first)
|
|
151
|
+
export const FullTimeline: Story = {
|
|
152
|
+
args: {
|
|
153
|
+
phase: makePhase(5, '2024-08-01T09:00:00.000Z', null, [
|
|
154
|
+
{ ...sampleEvent, id: 4, type: 'proposal_accepted', happened_at: '2024-08-05T10:00:00.000Z' },
|
|
155
|
+
]),
|
|
156
|
+
index: 0,
|
|
157
|
+
phases: [
|
|
158
|
+
makePhase(5, '2024-08-01T09:00:00.000Z', null),
|
|
159
|
+
makePhase(4, '2024-06-01T09:00:00.000Z', '2024-08-01T09:00:00.000Z'),
|
|
160
|
+
makePhase(1, '2024-01-01T09:00:00.000Z', '2024-06-01T09:00:00.000Z'),
|
|
161
|
+
],
|
|
162
|
+
isSplitView: true,
|
|
163
|
+
isLast: false,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Phase with no entered_at or exited_at dates
|
|
168
|
+
export const NoDates: Story = {
|
|
169
|
+
args: {
|
|
170
|
+
phase: makePhase(2, null, null),
|
|
171
|
+
index: 0,
|
|
172
|
+
phases: [makePhase(2, null, null), makePhase(1, null, null)],
|
|
173
|
+
isSplitView: true,
|
|
174
|
+
isLast: false,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Inactive phase (value 0)
|
|
179
|
+
export const InactivePhase: Story = {
|
|
180
|
+
args: {
|
|
181
|
+
phase: makePhase(0),
|
|
182
|
+
index: 0,
|
|
183
|
+
phases: [makePhase(0)],
|
|
184
|
+
isSplitView: true,
|
|
185
|
+
isLast: true,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Wonen phase (value 6 — highest)
|
|
190
|
+
export const WonenPhase: Story = {
|
|
191
|
+
args: {
|
|
192
|
+
phase: makePhase(6),
|
|
193
|
+
index: 0,
|
|
194
|
+
phases: [makePhase(6), makePhase(5)],
|
|
195
|
+
isSplitView: true,
|
|
196
|
+
isLast: false,
|
|
197
|
+
},
|
|
198
|
+
}
|