mvc-kit 2.13.0 → 2.13.1
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/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +4 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import { ShiftViewModel } from '../../viewmodels/ShiftViewModel';
|
|
3
|
+
import { ShiftTimer } from './ShiftTimer';
|
|
4
|
+
import { SiteSelector } from './SiteSelector';
|
|
5
|
+
import { PendingBanner } from '../shared/PendingBanner';
|
|
6
|
+
import { Spinner } from '../shared/Spinner';
|
|
7
|
+
|
|
8
|
+
export function ShiftPage() {
|
|
9
|
+
const [state, vm] = useLocal(ShiftViewModel, { selectedSiteId: null, now: Date.now() });
|
|
10
|
+
const { loading } = vm.async.load;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
<h1 className="page-title">My Shift</h1>
|
|
15
|
+
|
|
16
|
+
<PendingBanner
|
|
17
|
+
pending={vm.pending}
|
|
18
|
+
label="shift operations"
|
|
19
|
+
renderEntry={entry => (
|
|
20
|
+
<span>{entry.meta?.action} at {entry.meta?.siteName} — {entry.error}</span>
|
|
21
|
+
)}
|
|
22
|
+
/>
|
|
23
|
+
|
|
24
|
+
{loading && <Spinner />}
|
|
25
|
+
|
|
26
|
+
{!loading && !vm.isOnShift && (
|
|
27
|
+
<div className="card">
|
|
28
|
+
<h2 style={{ marginBottom: '1rem' }}>Clock In</h2>
|
|
29
|
+
<p style={{ color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
|
|
30
|
+
Select a work site and clock in to start your shift.
|
|
31
|
+
</p>
|
|
32
|
+
<SiteSelector
|
|
33
|
+
sites={vm.availableSites}
|
|
34
|
+
selectedId={state.selectedSiteId}
|
|
35
|
+
onSelect={id => vm.selectSite(id)}
|
|
36
|
+
/>
|
|
37
|
+
<button
|
|
38
|
+
className="btn btn-primary"
|
|
39
|
+
disabled={!state.selectedSiteId || vm.hasPendingOps}
|
|
40
|
+
onClick={() => vm.clockIn()}
|
|
41
|
+
>
|
|
42
|
+
{vm.hasPendingOps ? 'Processing...' : 'Clock In'}
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
|
|
47
|
+
{!loading && vm.isOnShift && (
|
|
48
|
+
<div className="card">
|
|
49
|
+
<ShiftTimer
|
|
50
|
+
shiftTime={vm.formattedShiftTime}
|
|
51
|
+
breakTime={vm.formattedBreakTime}
|
|
52
|
+
siteName={vm.currentSiteName}
|
|
53
|
+
isOnBreak={vm.isOnBreak}
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
<div className="shift-actions">
|
|
57
|
+
{vm.isOnBreak ? (
|
|
58
|
+
<button
|
|
59
|
+
className="btn btn-primary"
|
|
60
|
+
onClick={() => vm.endBreak()}
|
|
61
|
+
disabled={vm.hasPendingOps}
|
|
62
|
+
>
|
|
63
|
+
End Break
|
|
64
|
+
</button>
|
|
65
|
+
) : (
|
|
66
|
+
<button
|
|
67
|
+
className="btn btn-secondary"
|
|
68
|
+
onClick={() => vm.startBreak()}
|
|
69
|
+
disabled={vm.hasPendingOps}
|
|
70
|
+
>
|
|
71
|
+
Start Break
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<button
|
|
76
|
+
className="btn btn-danger"
|
|
77
|
+
onClick={() => vm.clockOut()}
|
|
78
|
+
disabled={vm.hasPendingOps}
|
|
79
|
+
>
|
|
80
|
+
Clock Out
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{vm.hasFailedOps && (
|
|
85
|
+
<div style={{ marginTop: '1rem' }}>
|
|
86
|
+
<button className="btn btn-sm btn-primary" onClick={() => vm.retryAll()}>
|
|
87
|
+
Retry Failed
|
|
88
|
+
</button>
|
|
89
|
+
<button className="btn btn-sm btn-secondary" style={{ marginLeft: '0.5rem' }} onClick={() => vm.dismissAll()}>
|
|
90
|
+
Dismiss
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface ShiftTimerProps {
|
|
2
|
+
shiftTime: string;
|
|
3
|
+
breakTime: string;
|
|
4
|
+
siteName: string;
|
|
5
|
+
isOnBreak: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ShiftTimer({ shiftTime, breakTime, siteName, isOnBreak }: ShiftTimerProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="shift-timer">
|
|
11
|
+
<div className="shift-timer-site">{siteName}</div>
|
|
12
|
+
<div className="shift-timer-clock">
|
|
13
|
+
<div className="timer-value">{shiftTime}</div>
|
|
14
|
+
<div className="timer-label">Shift Duration</div>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="shift-timer-break">
|
|
17
|
+
<span className={`badge ${isOnBreak ? 'badge-maintenance' : 'badge-inactive'}`}>
|
|
18
|
+
{isOnBreak ? 'On Break' : 'Working'}
|
|
19
|
+
</span>
|
|
20
|
+
<span className="timer-break-value">Break: {breakTime}</span>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SiteState } from '../../types/site';
|
|
2
|
+
|
|
3
|
+
interface SiteSelectorProps {
|
|
4
|
+
sites: SiteState[];
|
|
5
|
+
selectedId: string | null;
|
|
6
|
+
onSelect: (id: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SiteSelector({ sites, selectedId, onSelect }: SiteSelectorProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="form-group">
|
|
12
|
+
<label className="form-label">Work Site</label>
|
|
13
|
+
<select
|
|
14
|
+
className="form-select"
|
|
15
|
+
value={selectedId ?? ''}
|
|
16
|
+
onChange={e => onSelect(e.target.value)}
|
|
17
|
+
>
|
|
18
|
+
<option value="">Select a site...</option>
|
|
19
|
+
{sites.map(site => (
|
|
20
|
+
<option key={site.id} value={site.id}>
|
|
21
|
+
{site.name} — {site.address}
|
|
22
|
+
</option>
|
|
23
|
+
))}
|
|
24
|
+
</select>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SiteState } from '../../types/site';
|
|
2
|
+
|
|
3
|
+
interface SiteFiltersProps {
|
|
4
|
+
search: string;
|
|
5
|
+
typeFilter: 'all' | SiteState['type'];
|
|
6
|
+
statusFilter: 'all' | SiteState['status'];
|
|
7
|
+
onSearchChange: (v: string) => void;
|
|
8
|
+
onTypeFilterChange: (v: 'all' | SiteState['type']) => void;
|
|
9
|
+
onStatusFilterChange: (v: 'all' | SiteState['status']) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SiteFilters({
|
|
13
|
+
search,
|
|
14
|
+
typeFilter,
|
|
15
|
+
statusFilter,
|
|
16
|
+
onSearchChange,
|
|
17
|
+
onTypeFilterChange,
|
|
18
|
+
onStatusFilterChange,
|
|
19
|
+
}: SiteFiltersProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="filters">
|
|
22
|
+
<div className="filter-group">
|
|
23
|
+
<label className="filter-label">Search</label>
|
|
24
|
+
<input
|
|
25
|
+
className="filter-input"
|
|
26
|
+
type="text"
|
|
27
|
+
value={search}
|
|
28
|
+
onChange={e => onSearchChange(e.target.value)}
|
|
29
|
+
placeholder="Search sites..."
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="filter-group">
|
|
33
|
+
<label className="filter-label">Type</label>
|
|
34
|
+
<select
|
|
35
|
+
className="filter-select"
|
|
36
|
+
value={typeFilter}
|
|
37
|
+
onChange={e => onTypeFilterChange(e.target.value as any)}
|
|
38
|
+
>
|
|
39
|
+
<option value="all">All Types</option>
|
|
40
|
+
<option value="residential">Residential</option>
|
|
41
|
+
<option value="commercial">Commercial</option>
|
|
42
|
+
<option value="industrial">Industrial</option>
|
|
43
|
+
<option value="infrastructure">Infrastructure</option>
|
|
44
|
+
</select>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="filter-group">
|
|
47
|
+
<label className="filter-label">Status</label>
|
|
48
|
+
<select
|
|
49
|
+
className="filter-select"
|
|
50
|
+
value={statusFilter}
|
|
51
|
+
onChange={e => onStatusFilterChange(e.target.value as any)}
|
|
52
|
+
>
|
|
53
|
+
<option value="all">All Statuses</option>
|
|
54
|
+
<option value="active">Active</option>
|
|
55
|
+
<option value="paused">Paused</option>
|
|
56
|
+
<option value="completed">Completed</option>
|
|
57
|
+
</select>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useLocal, DataTable } from 'mvc-kit/react';
|
|
2
|
+
import type { Column } from 'mvc-kit/react';
|
|
3
|
+
import { SitesViewModel } from '../../viewmodels/SitesViewModel';
|
|
4
|
+
import { SiteFilters } from './SiteFilters';
|
|
5
|
+
import { Spinner } from '../shared/Spinner';
|
|
6
|
+
import { ErrorBanner } from '../shared/ErrorBanner';
|
|
7
|
+
import type { SiteState } from '../../types/site';
|
|
8
|
+
|
|
9
|
+
const columns: Column<SiteState>[] = [
|
|
10
|
+
{
|
|
11
|
+
key: 'name',
|
|
12
|
+
header: 'Name',
|
|
13
|
+
render: site => <span style={{ fontWeight: 500 }}>{site.name}</span>,
|
|
14
|
+
sortable: true,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: 'type',
|
|
18
|
+
header: 'Type',
|
|
19
|
+
render: site => (
|
|
20
|
+
<span className={`badge badge-${site.type === 'residential' ? 'admin' : site.type === 'commercial' ? 'manager' : 'member'}`}>
|
|
21
|
+
{site.type}
|
|
22
|
+
</span>
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'address',
|
|
27
|
+
header: 'Address',
|
|
28
|
+
render: site => site.address,
|
|
29
|
+
sortable: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'status',
|
|
33
|
+
header: 'Status',
|
|
34
|
+
render: site => <span className={`badge badge-${site.status}`}>{site.status}</span>,
|
|
35
|
+
sortable: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'workerCount',
|
|
39
|
+
header: 'Workers',
|
|
40
|
+
render: site => site.workerCount,
|
|
41
|
+
sortable: true,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function SitesPage() {
|
|
46
|
+
const [state, vm] = useLocal(SitesViewModel, {
|
|
47
|
+
search: '',
|
|
48
|
+
typeFilter: 'all',
|
|
49
|
+
statusFilter: 'all',
|
|
50
|
+
});
|
|
51
|
+
const { loading, error } = vm.async.load;
|
|
52
|
+
// console.log('vm.paged', vm.paged)
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<h1 className="page-title">Work Sites</h1>
|
|
56
|
+
|
|
57
|
+
<SiteFilters
|
|
58
|
+
search={state.search}
|
|
59
|
+
typeFilter={state.typeFilter}
|
|
60
|
+
statusFilter={state.statusFilter}
|
|
61
|
+
onSearchChange={v => vm.setSearch(v)}
|
|
62
|
+
onTypeFilterChange={vm.setTypeFilter}
|
|
63
|
+
onStatusFilterChange={v => vm.setStatusFilter(v)}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<div className="results-bar">
|
|
67
|
+
<span>Showing {vm.filteredCount} of {vm.total}</span>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{vm.selection.hasSelection && (
|
|
71
|
+
<div className="selection-bar">
|
|
72
|
+
<span>{vm.selection.count} selected</span>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<DataTable
|
|
77
|
+
items={vm.paged}
|
|
78
|
+
columns={columns}
|
|
79
|
+
loading={loading}
|
|
80
|
+
error={error}
|
|
81
|
+
sort={vm.sorting}
|
|
82
|
+
selection={vm.selection}
|
|
83
|
+
pagination={vm.pagination}
|
|
84
|
+
paginationTotal={vm.filteredCount}
|
|
85
|
+
renderLoading={() => <Spinner />}
|
|
86
|
+
renderError={msg => <ErrorBanner message={msg} />}
|
|
87
|
+
renderEmpty={() => <div className="empty-state">No sites match your filters.</div>}
|
|
88
|
+
renderSortIndicator={({ active, direction }) => (
|
|
89
|
+
<span>{active ? (direction === 'asc' ? ' ↑' : ' ↓') : ''}</span>
|
|
90
|
+
)}
|
|
91
|
+
renderPagination={info => (
|
|
92
|
+
<div className="pagination-bar">
|
|
93
|
+
<button disabled={!info.hasPrev} onClick={info.goPrev}>Prev</button>
|
|
94
|
+
<span>Page {info.page} of {info.pageCount}</span>
|
|
95
|
+
<button disabled={!info.hasNext} onClick={info.goNext}>Next</button>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
className="table-container"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const __MVC_KIT_DEV__: boolean;
|
|
2
|
+
|
|
3
|
+
declare module 'react-dom/client' {
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
interface Root {
|
|
6
|
+
render(children: ReactNode): void;
|
|
7
|
+
unmount(): void;
|
|
8
|
+
}
|
|
9
|
+
export function createRoot(container: Element): Root;
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface MockWSConfig {
|
|
2
|
+
minInterval: number;
|
|
3
|
+
maxInterval: number;
|
|
4
|
+
generator: () => any;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MockWebSocket {
|
|
8
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
9
|
+
private closed = false;
|
|
10
|
+
|
|
11
|
+
constructor(private config: MockWSConfig) {}
|
|
12
|
+
|
|
13
|
+
connect(onMessage: (data: any) => void, signal?: AbortSignal): void {
|
|
14
|
+
if (signal?.aborted) return;
|
|
15
|
+
signal?.addEventListener('abort', () => this.close(), { once: true });
|
|
16
|
+
this.scheduleNext(onMessage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close(): void {
|
|
20
|
+
if (this.closed) return;
|
|
21
|
+
this.closed = true;
|
|
22
|
+
if (this.timer !== null) {
|
|
23
|
+
clearTimeout(this.timer);
|
|
24
|
+
this.timer = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private scheduleNext(onMessage: (data: any) => void): void {
|
|
29
|
+
if (this.closed) return;
|
|
30
|
+
const { minInterval, maxInterval, generator } = this.config;
|
|
31
|
+
const interval = minInterval + Math.random() * (maxInterval - minInterval);
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
if (this.closed) return;
|
|
34
|
+
onMessage(generator());
|
|
35
|
+
this.scheduleNext(onMessage);
|
|
36
|
+
}, interval);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
if (signal?.aborted) {
|
|
4
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const timer = setTimeout(resolve, ms);
|
|
9
|
+
|
|
10
|
+
signal?.addEventListener('abort', () => {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
13
|
+
}, { once: true });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function mockFetch<T>(data: T, ms?: number, signal?: AbortSignal): Promise<T> {
|
|
18
|
+
const jitter = 200 + Math.random() * 300;
|
|
19
|
+
await delay(ms ?? jitter, signal);
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ~30% failure rate to simulate unreliable network */
|
|
24
|
+
export async function mockFetchUnreliable<T>(data: T, ms?: number, signal?: AbortSignal): Promise<T> {
|
|
25
|
+
const jitter = 200 + Math.random() * 400;
|
|
26
|
+
await delay(ms ?? jitter, signal);
|
|
27
|
+
if (Math.random() < 0.3) {
|
|
28
|
+
throw new Error('Network error: request failed');
|
|
29
|
+
}
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { MessageState } from '../types/message';
|
|
2
|
+
import type { ConversationState } from '../types/conversation';
|
|
3
|
+
import type { FeedPage } from 'mvc-kit';
|
|
4
|
+
import { mockFetch, mockFetchUnreliable } from './delay';
|
|
5
|
+
|
|
6
|
+
const PAGE_SIZE = 15;
|
|
7
|
+
|
|
8
|
+
// --- Seed conversations ---
|
|
9
|
+
const MOCK_CONVERSATIONS: ConversationState[] = [
|
|
10
|
+
{ id: 'conv1', participantIds: ['w1', 'w2'], lastMessage: 'On my way to the site', unreadCount: 2, updatedAt: Date.now() - 60000 },
|
|
11
|
+
{ id: 'conv2', participantIds: ['w1', 'w3', 'w4'], lastMessage: 'Need more supplies at Riverside', unreadCount: 0, updatedAt: Date.now() - 120000 },
|
|
12
|
+
{ id: 'conv3', participantIds: ['w1', 'w5'], lastMessage: 'Break time?', unreadCount: 1, updatedAt: Date.now() - 300000 },
|
|
13
|
+
{ id: 'conv4', participantIds: ['w1', 'w6'], lastMessage: 'Wiring specs updated', unreadCount: 0, updatedAt: Date.now() - 600000 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// --- Seed messages ---
|
|
17
|
+
const allMessages = new Map<string, MessageState[]>();
|
|
18
|
+
|
|
19
|
+
function seedMessages(convId: string, participants: string[]): MessageState[] {
|
|
20
|
+
const texts = [
|
|
21
|
+
'Hey, are you at the site yet?',
|
|
22
|
+
'Just got here. Parking is a mess.',
|
|
23
|
+
'I\'ll be there in 10 minutes.',
|
|
24
|
+
'Can you check the electrical panel on floor 3?',
|
|
25
|
+
'Done. Everything looks good.',
|
|
26
|
+
'Great work today!',
|
|
27
|
+
'Need more 2x4s for the framing.',
|
|
28
|
+
'I\'ll call the supplier.',
|
|
29
|
+
'Safety inspection is at 2pm.',
|
|
30
|
+
'Got it, I\'ll make sure the crew knows.',
|
|
31
|
+
'Weather looks bad tomorrow.',
|
|
32
|
+
'We should cover the exposed sections.',
|
|
33
|
+
'Lunch run — want anything?',
|
|
34
|
+
'Just a coffee, thanks.',
|
|
35
|
+
'The foreman wants a progress report by EOD.',
|
|
36
|
+
'On it. Almost done with section B.',
|
|
37
|
+
'New blueprints came in.',
|
|
38
|
+
'I\'ll review them tonight.',
|
|
39
|
+
'Nice, the pour came out clean.',
|
|
40
|
+
'Couldn\'t have done it without the crew.',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const messages: MessageState[] = [];
|
|
44
|
+
const count = 20 + Math.floor(Math.random() * 10);
|
|
45
|
+
const baseTime = Date.now() - count * 60000;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < count; i++) {
|
|
48
|
+
messages.push({
|
|
49
|
+
id: `${convId}-msg${i}`,
|
|
50
|
+
conversationId: convId,
|
|
51
|
+
senderId: participants[i % participants.length]!,
|
|
52
|
+
text: texts[i % texts.length]!,
|
|
53
|
+
sentAt: baseTime + i * 60000,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return messages;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const conv of MOCK_CONVERSATIONS) {
|
|
60
|
+
allMessages.set(conv.id, seedMessages(conv.id, conv.participantIds));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- API ---
|
|
64
|
+
|
|
65
|
+
export async function fetchConversations(workerId: string, signal?: AbortSignal): Promise<ConversationState[]> {
|
|
66
|
+
const convs = MOCK_CONVERSATIONS.filter(c => c.participantIds.includes(workerId));
|
|
67
|
+
return mockFetch(convs, undefined, signal);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function fetchMessages(
|
|
71
|
+
conversationId: string,
|
|
72
|
+
signal?: AbortSignal,
|
|
73
|
+
opts?: { cursor?: string | null },
|
|
74
|
+
): Promise<FeedPage<MessageState>> {
|
|
75
|
+
const messages = allMessages.get(conversationId) ?? [];
|
|
76
|
+
// Sort newest first for cursor-based pagination
|
|
77
|
+
const sorted = [...messages].sort((a, b) => b.sentAt - a.sentAt);
|
|
78
|
+
|
|
79
|
+
let startIndex = 0;
|
|
80
|
+
if (opts?.cursor) {
|
|
81
|
+
const cursorIdx = sorted.findIndex(m => m.id === opts.cursor);
|
|
82
|
+
if (cursorIdx >= 0) startIndex = cursorIdx + 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const page = sorted.slice(startIndex, startIndex + PAGE_SIZE);
|
|
86
|
+
const hasMore = startIndex + PAGE_SIZE < sorted.length;
|
|
87
|
+
const cursor = page.length > 0 ? page[page.length - 1]!.id : null;
|
|
88
|
+
|
|
89
|
+
return mockFetch({ items: page.reverse(), hasMore, cursor }, undefined, signal);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function sendMessage(
|
|
93
|
+
conversationId: string,
|
|
94
|
+
senderId: string,
|
|
95
|
+
text: string,
|
|
96
|
+
signal?: AbortSignal,
|
|
97
|
+
): Promise<MessageState> {
|
|
98
|
+
const message: MessageState = {
|
|
99
|
+
id: `${conversationId}-msg${Date.now()}`,
|
|
100
|
+
conversationId,
|
|
101
|
+
senderId,
|
|
102
|
+
text,
|
|
103
|
+
sentAt: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Store in mock database
|
|
107
|
+
const existing = allMessages.get(conversationId) ?? [];
|
|
108
|
+
existing.push(message);
|
|
109
|
+
allMessages.set(conversationId, existing);
|
|
110
|
+
|
|
111
|
+
// Update conversation
|
|
112
|
+
const conv = MOCK_CONVERSATIONS.find(c => c.id === conversationId);
|
|
113
|
+
if (conv) {
|
|
114
|
+
conv.lastMessage = text;
|
|
115
|
+
conv.updatedAt = Date.now();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Unreliable — ~30% chance of failure to showcase Pending retries
|
|
119
|
+
return mockFetchUnreliable(message, undefined, signal);
|
|
120
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ShiftState } from '../types/shift';
|
|
2
|
+
import { mockFetchUnreliable } from './delay';
|
|
3
|
+
|
|
4
|
+
let shiftStore: ShiftState | null = null;
|
|
5
|
+
|
|
6
|
+
export async function fetchCurrentShift(workerId: string, signal?: AbortSignal): Promise<ShiftState | null> {
|
|
7
|
+
return mockFetchUnreliable(shiftStore, undefined, signal);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function clockIn(workerId: string, siteId: string, signal?: AbortSignal): Promise<ShiftState> {
|
|
11
|
+
const shift: ShiftState = {
|
|
12
|
+
id: `shift-${Date.now()}`,
|
|
13
|
+
workerId,
|
|
14
|
+
siteId,
|
|
15
|
+
clockIn: Date.now(),
|
|
16
|
+
clockOut: null,
|
|
17
|
+
breaks: [],
|
|
18
|
+
};
|
|
19
|
+
shiftStore = shift;
|
|
20
|
+
return mockFetchUnreliable(shift, undefined, signal);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function clockOut(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
|
|
24
|
+
if (!shiftStore || shiftStore.id !== shiftId) {
|
|
25
|
+
throw new Error('No active shift');
|
|
26
|
+
}
|
|
27
|
+
// End any open break
|
|
28
|
+
const breaks = shiftStore.breaks.map(b =>
|
|
29
|
+
b.end === null ? { ...b, end: Date.now() } : b,
|
|
30
|
+
);
|
|
31
|
+
shiftStore = { ...shiftStore, clockOut: Date.now(), breaks };
|
|
32
|
+
const result = shiftStore;
|
|
33
|
+
shiftStore = null;
|
|
34
|
+
return mockFetchUnreliable(result, undefined, signal);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function startBreak(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
|
|
38
|
+
if (!shiftStore || shiftStore.id !== shiftId) {
|
|
39
|
+
throw new Error('No active shift');
|
|
40
|
+
}
|
|
41
|
+
shiftStore = {
|
|
42
|
+
...shiftStore,
|
|
43
|
+
breaks: [...shiftStore.breaks, { start: Date.now(), end: null }],
|
|
44
|
+
};
|
|
45
|
+
return mockFetchUnreliable(shiftStore, undefined, signal);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function endBreak(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
|
|
49
|
+
if (!shiftStore || shiftStore.id !== shiftId) {
|
|
50
|
+
throw new Error('No active shift');
|
|
51
|
+
}
|
|
52
|
+
const breaks = shiftStore.breaks.map(b =>
|
|
53
|
+
b.end === null ? { ...b, end: Date.now() } : b,
|
|
54
|
+
);
|
|
55
|
+
shiftStore = { ...shiftStore, breaks };
|
|
56
|
+
return mockFetchUnreliable(shiftStore, undefined, signal);
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SiteState } from '../types/site';
|
|
2
|
+
|
|
3
|
+
export const MOCK_SITES: SiteState[] = [
|
|
4
|
+
{ id: 's1', name: 'Riverside Apartments', type: 'residential', status: 'active', address: '120 River St', workerCount: 8 },
|
|
5
|
+
{ id: 's2', name: 'Downtown Office Tower', type: 'commercial', status: 'active', address: '500 Main Ave', workerCount: 15 },
|
|
6
|
+
{ id: 's3', name: 'Harbor Industrial Park', type: 'industrial', status: 'active', address: '80 Harbor Rd', workerCount: 12 },
|
|
7
|
+
{ id: 's4', name: 'Maple Ridge Homes', type: 'residential', status: 'active', address: '45 Maple Ln', workerCount: 6 },
|
|
8
|
+
{ id: 's5', name: 'City Bridge Repair', type: 'infrastructure', status: 'paused', address: 'Bridge St', workerCount: 0 },
|
|
9
|
+
{ id: 's6', name: 'Tech Campus Phase 2', type: 'commercial', status: 'active', address: '200 Innovation Dr', workerCount: 20 },
|
|
10
|
+
{ id: 's7', name: 'Waterfront Condos', type: 'residential', status: 'completed', address: '10 Bay Walk', workerCount: 0 },
|
|
11
|
+
{ id: 's8', name: 'Factory Retrofit', type: 'industrial', status: 'active', address: '300 Industrial Blvd', workerCount: 9 },
|
|
12
|
+
{ id: 's9', name: 'Highway Extension', type: 'infrastructure', status: 'active', address: 'Route 9 North', workerCount: 25 },
|
|
13
|
+
{ id: 's10', name: 'Community Center', type: 'commercial', status: 'paused', address: '15 Park Ave', workerCount: 0 },
|
|
14
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkerState } from '../types/worker';
|
|
2
|
+
|
|
3
|
+
export const MOCK_WORKERS: WorkerState[] = [
|
|
4
|
+
{ id: 'w1', name: 'Marcus Johnson', email: 'marcus@example.com', role: 'foreman', status: 'available', avatar: 'MJ' },
|
|
5
|
+
{ id: 'w2', name: 'Sarah Chen', email: 'sarah@example.com', role: 'electrician', status: 'available', avatar: 'SC' },
|
|
6
|
+
{ id: 'w3', name: 'David Kowalski', email: 'david@example.com', role: 'plumber', status: 'on-shift', avatar: 'DK' },
|
|
7
|
+
{ id: 'w4', name: 'Ana Rodriguez', email: 'ana@example.com', role: 'carpenter', status: 'on-shift', avatar: 'AR' },
|
|
8
|
+
{ id: 'w5', name: 'James O\'Brien', email: 'james@example.com', role: 'laborer', status: 'on-break', avatar: 'JO' },
|
|
9
|
+
{ id: 'w6', name: 'Priya Patel', email: 'priya@example.com', role: 'electrician', status: 'offline', avatar: 'PP' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const CURRENT_WORKER = MOCK_WORKERS[0]!;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Model } from 'mvc-kit';
|
|
2
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
export interface ComposeMessageState {
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ComposeMessageModel extends Model<ComposeMessageState> {
|
|
9
|
+
setText(text: string) { this.set({ text }); }
|
|
10
|
+
|
|
11
|
+
protected validate(state: ComposeMessageState): ValidationErrors<ComposeMessageState> {
|
|
12
|
+
const errors: Partial<Record<keyof ComposeMessageState, string>> = {};
|
|
13
|
+
if (!state.text.trim()) errors.text = 'Message cannot be empty';
|
|
14
|
+
if (state.text.length > 500) errors.text = 'Message must be under 500 characters';
|
|
15
|
+
return errors;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Resource } from 'mvc-kit';
|
|
2
|
+
import type { ConversationState } from '../types/conversation';
|
|
3
|
+
import { fetchConversations } from '../mock/messages';
|
|
4
|
+
|
|
5
|
+
export class ConversationsResource extends Resource<ConversationState> {
|
|
6
|
+
async loadAll(workerId: string) {
|
|
7
|
+
const data = await fetchConversations(workerId, this.disposeSignal);
|
|
8
|
+
this.reset(data);
|
|
9
|
+
}
|
|
10
|
+
}
|