@joewinke/jatui 0.1.6 → 0.1.9
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/package.json +2 -1
- package/src/lib/components/AvatarUpload.svelte +211 -0
- package/src/lib/components/ChipInput.svelte +1 -0
- package/src/lib/components/ConfirmModal.svelte +1 -1
- package/src/lib/components/EmojiPicker.svelte +2 -0
- package/src/lib/components/FilePicker.svelte +419 -0
- package/src/lib/components/FilterDropdown.svelte +1 -0
- package/src/lib/components/FloatingActionBar.svelte +354 -0
- package/src/lib/components/ImageUpload.svelte +1 -0
- package/src/lib/components/MilestoneCard.svelte +98 -0
- package/src/lib/components/MilestoneTimeline.svelte +91 -0
- package/src/lib/components/Modal.svelte +1 -0
- package/src/lib/components/PdfThumbnail.svelte +61 -0
- package/src/lib/components/ResizableDivider.svelte +3 -1
- package/src/lib/components/ResizablePanel.svelte +3 -0
- package/src/lib/components/SearchDropdown.svelte +62 -3
- package/src/lib/components/SidebarUserFooter.svelte +34 -0
- package/src/lib/components/SignaturePad.svelte +2 -2
- package/src/lib/components/SortDropdown.svelte +4 -2
- package/src/lib/components/VoicePlayer.svelte +1 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +4 -4
- package/src/lib/components/messaging/CreateChannelModal.svelte +7 -7
- package/src/lib/components/messaging/MessageAttachment.svelte +4 -1
- package/src/lib/components/messaging/MessageInput.svelte +1 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +6 -6
- package/src/lib/index.ts +26 -1
- package/src/lib/types/filePicker.ts +14 -0
- package/src/lib/types/milestone.ts +22 -0
- package/src/lib/utils/dateFormatters.ts +41 -0
- package/src/lib/utils/taskUtils.ts +59 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FloatingActionBar Component
|
|
4
|
+
* Fixed bottom-center bar for bulk selection actions on tables/lists.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* <FloatingActionBar
|
|
8
|
+
* count={selectedTasks.size}
|
|
9
|
+
* {actions}
|
|
10
|
+
* {dropdowns}
|
|
11
|
+
* loading={bulkActionLoading}
|
|
12
|
+
* onClear={clearSelection}
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { fade } from 'svelte/transition';
|
|
17
|
+
|
|
18
|
+
export interface ActionButton {
|
|
19
|
+
/** Unique key */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Button label */
|
|
22
|
+
label: string;
|
|
23
|
+
/** SVG path string for a 24×24 icon (optional) */
|
|
24
|
+
icon?: string;
|
|
25
|
+
/** Color variant */
|
|
26
|
+
variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
|
|
27
|
+
/** Called when clicked */
|
|
28
|
+
onclick: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DropdownItem {
|
|
32
|
+
/** Display label */
|
|
33
|
+
label: string;
|
|
34
|
+
/** Optional colored dot (oklch string) */
|
|
35
|
+
dotColor?: string;
|
|
36
|
+
/** Optional SVG snippet rendered before the label */
|
|
37
|
+
iconSnippet?: import('svelte').Snippet;
|
|
38
|
+
/** Called when this item is clicked */
|
|
39
|
+
onclick: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ActionDropdown {
|
|
43
|
+
/** Unique key */
|
|
44
|
+
id: string;
|
|
45
|
+
/** Button label */
|
|
46
|
+
label: string;
|
|
47
|
+
/** SVG path string for a 24×24 icon (optional) */
|
|
48
|
+
icon?: string;
|
|
49
|
+
/** Color variant */
|
|
50
|
+
variant?: 'default' | 'primary' | 'info' | 'purple';
|
|
51
|
+
/** Items in the dropdown */
|
|
52
|
+
items: DropdownItem[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
/** Number of selected items — bar is hidden when 0 */
|
|
57
|
+
count: number;
|
|
58
|
+
/** Primary action buttons */
|
|
59
|
+
actions?: ActionButton[];
|
|
60
|
+
/** Dropdown menus (open upward from the bar) */
|
|
61
|
+
dropdowns?: ActionDropdown[];
|
|
62
|
+
/** Disables all buttons while true and shows a spinner */
|
|
63
|
+
loading?: boolean;
|
|
64
|
+
/** Called when the "Clear" button is clicked */
|
|
65
|
+
onClear?: () => void;
|
|
66
|
+
/** Label for the selection count (default: "selected") */
|
|
67
|
+
countLabel?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let {
|
|
71
|
+
count,
|
|
72
|
+
actions = [],
|
|
73
|
+
dropdowns = [],
|
|
74
|
+
loading = false,
|
|
75
|
+
onClear,
|
|
76
|
+
countLabel = 'selected',
|
|
77
|
+
}: Props = $props();
|
|
78
|
+
|
|
79
|
+
let openDropdownId = $state<string | null>(null);
|
|
80
|
+
|
|
81
|
+
// Close dropdown on outside click
|
|
82
|
+
$effect(() => {
|
|
83
|
+
if (!openDropdownId) return;
|
|
84
|
+
function handleClick(e: MouseEvent) {
|
|
85
|
+
if (!(e.target as HTMLElement).closest('.fab-dropdown-wrapper')) {
|
|
86
|
+
openDropdownId = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
|
|
90
|
+
return () => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
document.removeEventListener('click', handleClick);
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function variantClass(variant: ActionButton['variant'] | ActionDropdown['variant']): string {
|
|
97
|
+
switch (variant) {
|
|
98
|
+
case 'primary': return 'fab-btn-primary';
|
|
99
|
+
case 'danger': return 'fab-btn-danger';
|
|
100
|
+
case 'warning': return 'fab-btn-warning';
|
|
101
|
+
case 'success': return 'fab-btn-success';
|
|
102
|
+
case 'info': return 'fab-btn-info';
|
|
103
|
+
case 'purple': return 'fab-btn-purple';
|
|
104
|
+
default: return 'fab-btn-default';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
{#if count > 0}
|
|
110
|
+
<div class="fab" transition:fade={{ duration: 150 }}>
|
|
111
|
+
<span class="fab-count">{count} {countLabel}</span>
|
|
112
|
+
|
|
113
|
+
{#if actions.length > 0}
|
|
114
|
+
<div class="fab-divider"></div>
|
|
115
|
+
{#each actions as action}
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="fab-btn {variantClass(action.variant)}"
|
|
119
|
+
onclick={action.onclick}
|
|
120
|
+
disabled={loading}
|
|
121
|
+
>
|
|
122
|
+
{#if action.icon}
|
|
123
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
|
124
|
+
<!-- svelte-ignore a11y_consider_explicit_label -->
|
|
125
|
+
{@html action.icon}
|
|
126
|
+
</svg>
|
|
127
|
+
{/if}
|
|
128
|
+
{action.label}
|
|
129
|
+
</button>
|
|
130
|
+
{/each}
|
|
131
|
+
{/if}
|
|
132
|
+
|
|
133
|
+
{#if dropdowns.length > 0}
|
|
134
|
+
<div class="fab-divider"></div>
|
|
135
|
+
{#each dropdowns as dropdown}
|
|
136
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
137
|
+
<div
|
|
138
|
+
class="fab-dropdown-wrapper"
|
|
139
|
+
role="group"
|
|
140
|
+
onclick={(e) => e.stopPropagation()}
|
|
141
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
142
|
+
>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
class="fab-btn {variantClass(dropdown.variant)}"
|
|
146
|
+
onclick={() => openDropdownId = openDropdownId === dropdown.id ? null : dropdown.id}
|
|
147
|
+
disabled={loading}
|
|
148
|
+
>
|
|
149
|
+
{#if dropdown.icon}
|
|
150
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
|
151
|
+
<!-- svelte-ignore a11y_consider_explicit_label -->
|
|
152
|
+
{@html dropdown.icon}
|
|
153
|
+
</svg>
|
|
154
|
+
{/if}
|
|
155
|
+
{dropdown.label}
|
|
156
|
+
</button>
|
|
157
|
+
{#if openDropdownId === dropdown.id}
|
|
158
|
+
<div class="fab-dropdown" transition:fade={{ duration: 100 }}>
|
|
159
|
+
{#each dropdown.items as item}
|
|
160
|
+
<button
|
|
161
|
+
class="fab-dropdown-item"
|
|
162
|
+
onclick={() => { item.onclick(); openDropdownId = null; }}
|
|
163
|
+
>
|
|
164
|
+
{#if item.dotColor}
|
|
165
|
+
<span class="fab-dot" style="background: {item.dotColor};"></span>
|
|
166
|
+
{/if}
|
|
167
|
+
{#if item.iconSnippet}
|
|
168
|
+
{@render item.iconSnippet()}
|
|
169
|
+
{/if}
|
|
170
|
+
{item.label}
|
|
171
|
+
</button>
|
|
172
|
+
{/each}
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
{/each}
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
{#if onClear}
|
|
180
|
+
<div class="fab-divider"></div>
|
|
181
|
+
<button type="button" class="fab-btn fab-btn-clear" onclick={onClear} disabled={loading}>
|
|
182
|
+
Clear
|
|
183
|
+
</button>
|
|
184
|
+
{/if}
|
|
185
|
+
|
|
186
|
+
{#if loading}
|
|
187
|
+
<div class="fab-spinner"></div>
|
|
188
|
+
{/if}
|
|
189
|
+
</div>
|
|
190
|
+
{/if}
|
|
191
|
+
|
|
192
|
+
<style>
|
|
193
|
+
.fab {
|
|
194
|
+
position: fixed;
|
|
195
|
+
bottom: 1.5rem;
|
|
196
|
+
left: 50%;
|
|
197
|
+
transform: translateX(-50%);
|
|
198
|
+
z-index: 50;
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
gap: 0.5rem;
|
|
202
|
+
padding: 0.5rem 0.75rem;
|
|
203
|
+
background: oklch(0.16 0.02 250);
|
|
204
|
+
border: 1px solid oklch(0.30 0.02 250);
|
|
205
|
+
border-radius: 0.75rem;
|
|
206
|
+
box-shadow: 0 8px 32px oklch(0.05 0 0 / 0.6), 0 2px 8px oklch(0.05 0 0 / 0.3);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.fab-count {
|
|
210
|
+
font-size: 0.8125rem;
|
|
211
|
+
font-weight: 600;
|
|
212
|
+
color: oklch(0.85 0.02 250);
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.fab-divider {
|
|
217
|
+
width: 1px;
|
|
218
|
+
height: 1.25rem;
|
|
219
|
+
background: oklch(0.30 0.02 250);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.fab-btn {
|
|
223
|
+
display: flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
gap: 0.375rem;
|
|
226
|
+
padding: 0.375rem 0.625rem;
|
|
227
|
+
border: 1px solid transparent;
|
|
228
|
+
border-radius: 0.375rem;
|
|
229
|
+
font-size: 0.75rem;
|
|
230
|
+
font-weight: 500;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
transition: all 0.15s;
|
|
233
|
+
white-space: nowrap;
|
|
234
|
+
}
|
|
235
|
+
.fab-btn:disabled {
|
|
236
|
+
opacity: 0.5;
|
|
237
|
+
pointer-events: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Variants */
|
|
241
|
+
.fab-btn-default {
|
|
242
|
+
background: oklch(0.25 0.02 250 / 0.5);
|
|
243
|
+
color: oklch(0.75 0.02 250);
|
|
244
|
+
border-color: oklch(0.35 0.02 250);
|
|
245
|
+
}
|
|
246
|
+
.fab-btn-default:hover { background: oklch(0.30 0.02 250); }
|
|
247
|
+
|
|
248
|
+
.fab-btn-primary {
|
|
249
|
+
background: oklch(0.65 0.18 250 / 0.15);
|
|
250
|
+
color: oklch(0.82 0.14 250);
|
|
251
|
+
border-color: oklch(0.65 0.18 250 / 0.3);
|
|
252
|
+
}
|
|
253
|
+
.fab-btn-primary:hover { background: oklch(0.65 0.18 250 / 0.25); }
|
|
254
|
+
|
|
255
|
+
.fab-btn-danger {
|
|
256
|
+
background: oklch(0.55 0.18 30 / 0.15);
|
|
257
|
+
color: oklch(0.80 0.15 30);
|
|
258
|
+
border-color: oklch(0.55 0.18 30 / 0.3);
|
|
259
|
+
}
|
|
260
|
+
.fab-btn-danger:hover { background: oklch(0.55 0.18 30 / 0.25); }
|
|
261
|
+
|
|
262
|
+
.fab-btn-warning {
|
|
263
|
+
background: oklch(0.75 0.15 85 / 0.12);
|
|
264
|
+
color: oklch(0.80 0.12 85);
|
|
265
|
+
border-color: oklch(0.75 0.15 85 / 0.25);
|
|
266
|
+
}
|
|
267
|
+
.fab-btn-warning:hover { background: oklch(0.75 0.15 85 / 0.22); }
|
|
268
|
+
|
|
269
|
+
.fab-btn-success {
|
|
270
|
+
background: oklch(0.55 0.15 145 / 0.15);
|
|
271
|
+
color: oklch(0.80 0.12 145);
|
|
272
|
+
border-color: oklch(0.55 0.15 145 / 0.3);
|
|
273
|
+
}
|
|
274
|
+
.fab-btn-success:hover { background: oklch(0.55 0.15 145 / 0.25); }
|
|
275
|
+
|
|
276
|
+
.fab-btn-info {
|
|
277
|
+
background: oklch(0.55 0.15 200 / 0.15);
|
|
278
|
+
color: oklch(0.80 0.12 200);
|
|
279
|
+
border-color: oklch(0.55 0.15 200 / 0.3);
|
|
280
|
+
}
|
|
281
|
+
.fab-btn-info:hover { background: oklch(0.55 0.15 200 / 0.25); }
|
|
282
|
+
|
|
283
|
+
.fab-btn-purple {
|
|
284
|
+
background: oklch(0.55 0.15 300 / 0.15);
|
|
285
|
+
color: oklch(0.80 0.12 300);
|
|
286
|
+
border-color: oklch(0.55 0.15 300 / 0.3);
|
|
287
|
+
}
|
|
288
|
+
.fab-btn-purple:hover { background: oklch(0.55 0.15 300 / 0.25); }
|
|
289
|
+
|
|
290
|
+
.fab-btn-clear {
|
|
291
|
+
background: transparent;
|
|
292
|
+
color: oklch(0.65 0.02 250);
|
|
293
|
+
border-color: transparent;
|
|
294
|
+
}
|
|
295
|
+
.fab-btn-clear:hover {
|
|
296
|
+
background: oklch(0.25 0.02 250);
|
|
297
|
+
color: oklch(0.80 0.02 250);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.fab-spinner {
|
|
301
|
+
width: 14px;
|
|
302
|
+
height: 14px;
|
|
303
|
+
border: 2px solid oklch(0.35 0.02 250);
|
|
304
|
+
border-top-color: oklch(0.70 0.15 240);
|
|
305
|
+
border-radius: 50%;
|
|
306
|
+
animation: fab-spin 0.6s linear infinite;
|
|
307
|
+
flex-shrink: 0;
|
|
308
|
+
}
|
|
309
|
+
@keyframes fab-spin {
|
|
310
|
+
to { transform: rotate(360deg); }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* Dropdown */
|
|
314
|
+
.fab-dropdown-wrapper {
|
|
315
|
+
position: relative;
|
|
316
|
+
}
|
|
317
|
+
.fab-dropdown {
|
|
318
|
+
position: absolute;
|
|
319
|
+
bottom: calc(100% + 8px);
|
|
320
|
+
left: 50%;
|
|
321
|
+
transform: translateX(-50%);
|
|
322
|
+
min-width: 160px;
|
|
323
|
+
background: oklch(0.18 0.02 250);
|
|
324
|
+
border: 1px solid oklch(0.30 0.02 250);
|
|
325
|
+
border-radius: 0.5rem;
|
|
326
|
+
padding: 0.375rem;
|
|
327
|
+
box-shadow: 0 8px 24px oklch(0.05 0 0 / 0.5);
|
|
328
|
+
z-index: 60;
|
|
329
|
+
}
|
|
330
|
+
.fab-dropdown-item {
|
|
331
|
+
display: flex;
|
|
332
|
+
align-items: center;
|
|
333
|
+
gap: 0.5rem;
|
|
334
|
+
width: 100%;
|
|
335
|
+
padding: 0.375rem 0.625rem;
|
|
336
|
+
border: none;
|
|
337
|
+
background: transparent;
|
|
338
|
+
color: oklch(0.80 0.02 250);
|
|
339
|
+
font-size: 0.75rem;
|
|
340
|
+
text-align: left;
|
|
341
|
+
border-radius: 0.375rem;
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
transition: background 0.1s;
|
|
344
|
+
white-space: nowrap;
|
|
345
|
+
}
|
|
346
|
+
.fab-dropdown-item:hover { background: oklch(0.25 0.02 250); }
|
|
347
|
+
|
|
348
|
+
.fab-dot {
|
|
349
|
+
width: 8px;
|
|
350
|
+
height: 8px;
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
flex-shrink: 0;
|
|
353
|
+
}
|
|
354
|
+
</style>
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
class="btn btn-sm btn-circle absolute top-2 right-2 bg-base-100/80"
|
|
66
66
|
onclick={clearImage}
|
|
67
67
|
{disabled}
|
|
68
|
+
aria-label="Remove image"
|
|
68
69
|
>
|
|
69
70
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
70
71
|
</button>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Milestone, MilestoneStatus } from '../types/milestone'
|
|
3
|
+
|
|
4
|
+
interface LinkedTask {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
status: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
milestone: Milestone
|
|
12
|
+
tasks?: LinkedTask[]
|
|
13
|
+
isCurrent?: boolean
|
|
14
|
+
currency?: string
|
|
15
|
+
onAcceptAndPay?: (milestone: Milestone) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { milestone, tasks = [], isCurrent = false, currency = 'USD', onAcceptAndPay }: Props = $props()
|
|
19
|
+
|
|
20
|
+
const statusConfig: Record<MilestoneStatus, { label: string; class: string; icon: string }> = {
|
|
21
|
+
pending: { label: 'Pending', class: 'badge-ghost', icon: '○' },
|
|
22
|
+
delivered: { label: 'Delivered', class: 'badge-info', icon: '◉' },
|
|
23
|
+
accepted: { label: 'Accepted', class: 'badge-warning', icon: '◉' },
|
|
24
|
+
paid: { label: 'Paid', class: 'badge-success', icon: '●' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const config = $derived(statusConfig[milestone.status])
|
|
28
|
+
|
|
29
|
+
function formatCurrency(amount: number) {
|
|
30
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDate(dateStr: string | null) {
|
|
34
|
+
if (!dateStr) return null
|
|
35
|
+
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
36
|
+
month: 'short',
|
|
37
|
+
day: 'numeric',
|
|
38
|
+
year: 'numeric'
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<div
|
|
44
|
+
class="card bg-base-100 border border-base-300 {isCurrent
|
|
45
|
+
? 'ring-2 ring-primary/50 shadow-lg'
|
|
46
|
+
: ''}"
|
|
47
|
+
>
|
|
48
|
+
<div class="card-body p-4 gap-2">
|
|
49
|
+
<div class="flex items-start justify-between gap-2">
|
|
50
|
+
<div class="flex-1 min-w-0">
|
|
51
|
+
<h4 class="font-semibold text-base-content text-sm">{milestone.name}</h4>
|
|
52
|
+
{#if milestone.description}
|
|
53
|
+
<p class="text-xs text-base-content/60 mt-0.5">{milestone.description}</p>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
<div class="flex items-center gap-2 flex-shrink-0">
|
|
57
|
+
<span class="font-semibold text-sm">{formatCurrency(milestone.amount)}</span>
|
|
58
|
+
<span class="badge badge-sm {config.class}">{config.icon} {config.label}</span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{#if milestone.acceptance_criteria}
|
|
63
|
+
<div class="text-xs text-base-content/50 mt-1">
|
|
64
|
+
<span class="font-medium">Acceptance:</span>
|
|
65
|
+
{milestone.acceptance_criteria}
|
|
66
|
+
</div>
|
|
67
|
+
{/if}
|
|
68
|
+
|
|
69
|
+
{#if tasks.length > 0}
|
|
70
|
+
<div class="flex flex-wrap gap-1 mt-2">
|
|
71
|
+
{#each tasks as task}
|
|
72
|
+
<span class="badge badge-xs badge-outline gap-1">
|
|
73
|
+
<span class="w-1.5 h-1.5 rounded-full {task.status === 'deployed' || task.status === 'accepted' || task.status === 'closed' ? 'bg-success' : task.status === 'in_progress' || task.status === 'submitted' ? 'bg-warning' : 'bg-base-300'}"></span>
|
|
74
|
+
{task.title}
|
|
75
|
+
</span>
|
|
76
|
+
{/each}
|
|
77
|
+
</div>
|
|
78
|
+
{/if}
|
|
79
|
+
|
|
80
|
+
<div class="flex items-center gap-3 text-xs text-base-content/40 mt-1">
|
|
81
|
+
<span>{milestone.percentage}% of total</span>
|
|
82
|
+
{#if milestone.delivered_at}
|
|
83
|
+
<span>Delivered {formatDate(milestone.delivered_at)}</span>
|
|
84
|
+
{/if}
|
|
85
|
+
{#if milestone.paid_at}
|
|
86
|
+
<span>Paid {formatDate(milestone.paid_at)}</span>
|
|
87
|
+
{/if}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if isCurrent && milestone.status === 'delivered' && onAcceptAndPay}
|
|
91
|
+
<div class="mt-2">
|
|
92
|
+
<button class="btn btn-primary btn-sm" onclick={() => onAcceptAndPay(milestone)}>
|
|
93
|
+
Accept & Pay {formatCurrency(milestone.amount)}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Milestone } from '../types/milestone'
|
|
3
|
+
import MilestoneCard from './MilestoneCard.svelte'
|
|
4
|
+
|
|
5
|
+
interface LinkedTask {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
status: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
milestones: Milestone[]
|
|
13
|
+
linkedTasks?: Record<string, LinkedTask[]>
|
|
14
|
+
currency?: string
|
|
15
|
+
onAcceptAndPay?: (milestone: Milestone) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { milestones, linkedTasks = {}, currency = 'USD', onAcceptAndPay }: Props = $props()
|
|
19
|
+
|
|
20
|
+
const sorted = $derived([...milestones].sort((a, b) => a.sort_order - b.sort_order))
|
|
21
|
+
|
|
22
|
+
const currentIndex = $derived(sorted.findIndex((m) => m.status !== 'paid'))
|
|
23
|
+
|
|
24
|
+
const paidCount = $derived(sorted.filter((m) => m.status === 'paid').length)
|
|
25
|
+
const totalAmount = $derived(sorted.reduce((sum, m) => sum + m.amount, 0))
|
|
26
|
+
const paidAmount = $derived(
|
|
27
|
+
sorted.filter((m) => m.status === 'paid').reduce((sum, m) => sum + m.amount, 0)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function formatCurrency(amount: number) {
|
|
31
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="space-y-1">
|
|
36
|
+
<!-- Progress summary -->
|
|
37
|
+
<div class="flex items-center justify-between text-sm mb-4">
|
|
38
|
+
<span class="text-base-content/60">
|
|
39
|
+
{paidCount} of {sorted.length} milestones paid
|
|
40
|
+
</span>
|
|
41
|
+
<span class="font-semibold">
|
|
42
|
+
{formatCurrency(paidAmount)} / {formatCurrency(totalAmount)}
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- Progress bar -->
|
|
47
|
+
{#if totalAmount > 0}
|
|
48
|
+
<div class="w-full bg-base-300 rounded-full h-2 mb-6">
|
|
49
|
+
<div
|
|
50
|
+
class="bg-success h-2 rounded-full transition-all duration-500"
|
|
51
|
+
style="width: {(paidAmount / totalAmount) * 100}%"
|
|
52
|
+
></div>
|
|
53
|
+
</div>
|
|
54
|
+
{/if}
|
|
55
|
+
|
|
56
|
+
<!-- Timeline -->
|
|
57
|
+
<div class="relative">
|
|
58
|
+
{#each sorted as milestone, i (milestone.id)}
|
|
59
|
+
<div class="flex gap-4">
|
|
60
|
+
<!-- Timeline connector -->
|
|
61
|
+
<div class="flex flex-col items-center w-6 flex-shrink-0">
|
|
62
|
+
<div
|
|
63
|
+
class="w-3 h-3 rounded-full border-2 {milestone.status === 'paid'
|
|
64
|
+
? 'bg-success border-success'
|
|
65
|
+
: i === currentIndex
|
|
66
|
+
? 'bg-primary border-primary'
|
|
67
|
+
: 'bg-base-300 border-base-300'}"
|
|
68
|
+
></div>
|
|
69
|
+
{#if i < sorted.length - 1}
|
|
70
|
+
<div
|
|
71
|
+
class="w-0.5 flex-1 min-h-4 {milestone.status === 'paid'
|
|
72
|
+
? 'bg-success/50'
|
|
73
|
+
: 'bg-base-300'}"
|
|
74
|
+
></div>
|
|
75
|
+
{/if}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Card -->
|
|
79
|
+
<div class="flex-1 pb-4">
|
|
80
|
+
<MilestoneCard
|
|
81
|
+
{milestone}
|
|
82
|
+
tasks={linkedTasks[milestone.id] || []}
|
|
83
|
+
isCurrent={i === currentIndex}
|
|
84
|
+
{currency}
|
|
85
|
+
{onAcceptAndPay}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
@@ -155,6 +155,7 @@
|
|
|
155
155
|
onkeydown={handleKeyDown}
|
|
156
156
|
tabindex="-1"
|
|
157
157
|
>
|
|
158
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
158
159
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
159
160
|
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
|
160
161
|
<div class={contentClasses}>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte"
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** URL or path to the PDF file */
|
|
6
|
+
src: string
|
|
7
|
+
/** CSS class for the container */
|
|
8
|
+
class?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { src, class: className = "" }: Props = $props()
|
|
12
|
+
let canvas: HTMLCanvasElement
|
|
13
|
+
let loading = $state(true)
|
|
14
|
+
let failed = $state(false)
|
|
15
|
+
|
|
16
|
+
onMount(async () => {
|
|
17
|
+
try {
|
|
18
|
+
const pdfjsLib = await import("pdfjs-dist")
|
|
19
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|
20
|
+
"pdfjs-dist/build/pdf.worker.mjs",
|
|
21
|
+
import.meta.url,
|
|
22
|
+
).toString()
|
|
23
|
+
|
|
24
|
+
const response = await fetch(src)
|
|
25
|
+
const buffer = await response.arrayBuffer()
|
|
26
|
+
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
|
|
27
|
+
const page = await pdf.getPage(1)
|
|
28
|
+
|
|
29
|
+
const containerWidth = canvas.parentElement?.clientWidth || 300
|
|
30
|
+
const viewport = page.getViewport({ scale: 1 })
|
|
31
|
+
const scale = containerWidth / viewport.width
|
|
32
|
+
const scaled = page.getViewport({ scale })
|
|
33
|
+
|
|
34
|
+
canvas.width = scaled.width
|
|
35
|
+
canvas.height = scaled.height
|
|
36
|
+
|
|
37
|
+
await page.render({
|
|
38
|
+
canvasContext: canvas.getContext("2d")!,
|
|
39
|
+
viewport: scaled,
|
|
40
|
+
}).promise
|
|
41
|
+
|
|
42
|
+
loading = false
|
|
43
|
+
} catch {
|
|
44
|
+
failed = true
|
|
45
|
+
loading = false
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if failed}
|
|
51
|
+
<div class={className}>
|
|
52
|
+
<svg class="h-14 w-14 text-error/60" fill="currentColor" viewBox="0 0 24 24">
|
|
53
|
+
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M10.92,12.31C10.68,11.54 10.15,9.08 11.55,9.04C12.95,9 12.03,12.16 12.03,12.16C12.42,13.65 14.05,14.72 14.05,14.72C14.55,14.57 17.4,14.24 17,15.72C16.57,17.2 13.5,15.81 13.5,15.81C11.55,15.95 10.09,16.47 10.09,16.47C8.96,18.58 7.64,19.5 7.1,18.61C6.43,17.5 9.23,16.07 9.23,16.07C10.68,13.67 10.92,12.31 10.92,12.31Z" />
|
|
54
|
+
</svg>
|
|
55
|
+
</div>
|
|
56
|
+
{:else}
|
|
57
|
+
{#if loading}
|
|
58
|
+
<span class="loading loading-spinner loading-sm text-base-content/30"></span>
|
|
59
|
+
{/if}
|
|
60
|
+
<canvas bind:this={canvas} class="w-full h-full object-cover {className}" class:hidden={loading}></canvas>
|
|
61
|
+
{/if}
|
|
@@ -121,6 +121,8 @@
|
|
|
121
121
|
const isExpanded = $derived(isDragging || isHovering || isNearby);
|
|
122
122
|
</script>
|
|
123
123
|
|
|
124
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
125
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
124
126
|
<div
|
|
125
127
|
bind:this={dividerElement}
|
|
126
128
|
class="divider-container flex items-center justify-center select-none {className} {isCollapsed ? 'cursor-pointer divider-collapsed' : 'cursor-row-resize'} {isDragging ? 'bg-primary/25' : isExpanded && isCollapsed ? 'bg-primary/40' : isExpanded ? 'bg-primary/15' : ''}"
|
|
@@ -128,7 +130,7 @@
|
|
|
128
130
|
class:dragging={isDragging}
|
|
129
131
|
role="separator"
|
|
130
132
|
aria-orientation="horizontal"
|
|
131
|
-
aria-
|
|
133
|
+
aria-label={isCollapsed ? 'Expand panel' : 'Resize panel'}
|
|
132
134
|
tabindex="0"
|
|
133
135
|
onmousedown={handleMouseDown}
|
|
134
136
|
onmouseenter={handleMouseEnter}
|
|
@@ -165,6 +165,7 @@
|
|
|
165
165
|
style="width: {width}px;"
|
|
166
166
|
>
|
|
167
167
|
{#if enableResize}
|
|
168
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
168
169
|
<div
|
|
169
170
|
class="resize-handle left"
|
|
170
171
|
style="right: 0;"
|
|
@@ -173,6 +174,7 @@
|
|
|
173
174
|
>
|
|
174
175
|
<div class="handle-indicator"></div>
|
|
175
176
|
</div>
|
|
177
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
176
178
|
<div
|
|
177
179
|
class="resize-handle right"
|
|
178
180
|
style="left: 0;"
|
|
@@ -184,6 +186,7 @@
|
|
|
184
186
|
{/if}
|
|
185
187
|
|
|
186
188
|
{#if enableDragToSwap}
|
|
189
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
187
190
|
<div
|
|
188
191
|
class="drag-handle"
|
|
189
192
|
onmousedown={handleDragStart}
|