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.
Files changed (200) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
  3. package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
  4. package/agent-config/lib/install-claude.mjs +8 -2
  5. package/examples/primitive/channel.ts +109 -0
  6. package/examples/primitive/collection.ts +118 -0
  7. package/examples/primitive/controller.ts +118 -0
  8. package/examples/primitive/counter.ts +108 -0
  9. package/examples/primitive/env.d.ts +1 -0
  10. package/examples/primitive/eventbus.ts +77 -0
  11. package/examples/primitive/feed.ts +162 -0
  12. package/examples/primitive/model.ts +82 -0
  13. package/examples/primitive/pagination.ts +91 -0
  14. package/examples/primitive/pending.ts +189 -0
  15. package/examples/primitive/persistent-collection.ts +116 -0
  16. package/examples/primitive/resource.ts +114 -0
  17. package/examples/primitive/selection.ts +96 -0
  18. package/examples/primitive/sorting.ts +112 -0
  19. package/examples/primitive/timer.ts +58 -0
  20. package/examples/primitive/trackable.ts +225 -0
  21. package/examples/primitive/tsconfig.json +20 -0
  22. package/examples/primitive/viewmodel-service.ts +161 -0
  23. package/examples/react/AuthExample/index.html +12 -0
  24. package/examples/react/AuthExample/src/App.tsx +29 -0
  25. package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
  26. package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
  27. package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
  28. package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
  29. package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
  30. package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
  31. package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
  32. package/examples/react/AuthExample/src/env.d.ts +10 -0
  33. package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
  34. package/examples/react/AuthExample/src/main.tsx +10 -0
  35. package/examples/react/AuthExample/src/mock/api.ts +78 -0
  36. package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
  37. package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
  38. package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
  39. package/examples/react/AuthExample/src/styles.css +445 -0
  40. package/examples/react/AuthExample/src/types/auth.ts +12 -0
  41. package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
  42. package/examples/react/AuthExample/tsconfig.json +22 -0
  43. package/examples/react/AuthExample/vite.config.ts +18 -0
  44. package/examples/react/ComplexApp/index.html +12 -0
  45. package/examples/react/ComplexApp/src/App.tsx +17 -0
  46. package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
  47. package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
  48. package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
  49. package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
  50. package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
  51. package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
  52. package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
  53. package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
  54. package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
  55. package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
  56. package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
  57. package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
  58. package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
  59. package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
  60. package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
  61. package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
  62. package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
  63. package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
  64. package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
  65. package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
  66. package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
  67. package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
  68. package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
  69. package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
  70. package/examples/react/ComplexApp/src/env.d.ts +10 -0
  71. package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
  72. package/examples/react/ComplexApp/src/main.tsx +10 -0
  73. package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
  74. package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
  75. package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
  76. package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
  77. package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
  78. package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
  79. package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
  80. package/examples/react/ComplexApp/src/styles.css +463 -0
  81. package/examples/react/ComplexApp/src/types/activity.ts +8 -0
  82. package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
  83. package/examples/react/ComplexApp/src/types/social.ts +8 -0
  84. package/examples/react/ComplexApp/src/types/users.ts +6 -0
  85. package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
  86. package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
  87. package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
  88. package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
  89. package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
  90. package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
  91. package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
  92. package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
  93. package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
  94. package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
  95. package/examples/react/ComplexApp/tsconfig.json +22 -0
  96. package/examples/react/ComplexApp/vite.config.ts +18 -0
  97. package/examples/react/FullApp/index.html +12 -0
  98. package/examples/react/FullApp/src/App.tsx +28 -0
  99. package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
  100. package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
  101. package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
  102. package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
  103. package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
  104. package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
  105. package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
  106. package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
  107. package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
  108. package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
  109. package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
  110. package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
  111. package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
  112. package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
  113. package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
  114. package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
  115. package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
  116. package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
  117. package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
  118. package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
  119. package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
  120. package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
  121. package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
  122. package/examples/react/FullApp/src/env.d.ts +10 -0
  123. package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
  124. package/examples/react/FullApp/src/main.tsx +10 -0
  125. package/examples/react/FullApp/src/mock/delay.ts +21 -0
  126. package/examples/react/FullApp/src/mock/locations.ts +76 -0
  127. package/examples/react/FullApp/src/mock/messages.ts +237 -0
  128. package/examples/react/FullApp/src/mock/users.ts +84 -0
  129. package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
  130. package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
  131. package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
  132. package/examples/react/FullApp/src/services/AuthService.ts +18 -0
  133. package/examples/react/FullApp/src/services/LocationService.ts +23 -0
  134. package/examples/react/FullApp/src/services/MessageService.ts +65 -0
  135. package/examples/react/FullApp/src/services/UserService.ts +23 -0
  136. package/examples/react/FullApp/src/styles.css +767 -0
  137. package/examples/react/FullApp/src/types/conversation.ts +7 -0
  138. package/examples/react/FullApp/src/types/location.ts +12 -0
  139. package/examples/react/FullApp/src/types/message.ts +7 -0
  140. package/examples/react/FullApp/src/types/user.ts +10 -0
  141. package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
  142. package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
  143. package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
  144. package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
  145. package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
  146. package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
  147. package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
  148. package/examples/react/FullApp/tsconfig.json +22 -0
  149. package/examples/react/FullApp/vite.config.ts +18 -0
  150. package/examples/react/WorkerApp/index.html +12 -0
  151. package/examples/react/WorkerApp/src/App.tsx +24 -0
  152. package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
  153. package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
  154. package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
  155. package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
  156. package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
  157. package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
  158. package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
  159. package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
  160. package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
  161. package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
  162. package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
  163. package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
  164. package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
  165. package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
  166. package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
  167. package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
  168. package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
  169. package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
  170. package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
  171. package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
  172. package/examples/react/WorkerApp/src/env.d.ts +10 -0
  173. package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
  174. package/examples/react/WorkerApp/src/main.tsx +10 -0
  175. package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
  176. package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
  177. package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
  178. package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
  179. package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
  180. package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
  181. package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
  182. package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
  183. package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
  184. package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
  185. package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
  186. package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
  187. package/examples/react/WorkerApp/src/styles.css +756 -0
  188. package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
  189. package/examples/react/WorkerApp/src/types/message.ts +7 -0
  190. package/examples/react/WorkerApp/src/types/shift.ts +13 -0
  191. package/examples/react/WorkerApp/src/types/site.ts +8 -0
  192. package/examples/react/WorkerApp/src/types/worker.ts +8 -0
  193. package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
  194. package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
  195. package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
  196. package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
  197. package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
  198. package/examples/react/WorkerApp/tsconfig.json +22 -0
  199. package/examples/react/WorkerApp/vite.config.ts +18 -0
  200. package/package.json +4 -2
@@ -0,0 +1,112 @@
1
+ import { useField } from 'mvc-kit/react';
2
+ import type { LocationFormModel } from '../../models/LocationFormModel';
3
+
4
+ interface LocationFormProps {
5
+ model: LocationFormModel;
6
+ onSave: () => void;
7
+ saving: boolean;
8
+ }
9
+
10
+ export function LocationForm({ model, onSave, saving }: LocationFormProps) {
11
+ const name = useField(model, 'name');
12
+ const type = useField(model, 'type');
13
+ const city = useField(model, 'city');
14
+ const stateName = useField(model, 'state');
15
+ const address = useField(model, 'address');
16
+ const capacity = useField(model, 'capacity');
17
+
18
+ const handleSubmit = (e: React.FormEvent) => {
19
+ e.preventDefault();
20
+ onSave();
21
+ };
22
+
23
+ return (
24
+ <form onSubmit={handleSubmit}>
25
+ <div className="form-group">
26
+ <label className="form-label">Name</label>
27
+ <input
28
+ className={`form-input ${name.error ? 'error' : ''}`}
29
+ value={name.value}
30
+ onChange={e => model.setName(e.target.value)}
31
+ />
32
+ {name.error && <div className="form-error">{name.error}</div>}
33
+ </div>
34
+
35
+ <div className="form-group">
36
+ <label className="form-label">Type</label>
37
+ <select
38
+ className="form-select"
39
+ value={type.value}
40
+ onChange={e => model.setType(e.target.value as 'office' | 'warehouse' | 'retail')}
41
+ >
42
+ <option value="office">Office</option>
43
+ <option value="warehouse">Warehouse</option>
44
+ <option value="retail">Retail</option>
45
+ </select>
46
+ </div>
47
+
48
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
49
+ <div className="form-group">
50
+ <label className="form-label">City</label>
51
+ <input
52
+ className={`form-input ${city.error ? 'error' : ''}`}
53
+ value={city.value}
54
+ onChange={e => model.setCity(e.target.value)}
55
+ />
56
+ {city.error && <div className="form-error">{city.error}</div>}
57
+ </div>
58
+
59
+ <div className="form-group">
60
+ <label className="form-label">State</label>
61
+ <input
62
+ className={`form-input ${stateName.error ? 'error' : ''}`}
63
+ value={stateName.value}
64
+ onChange={e => model.setStateName(e.target.value)}
65
+ />
66
+ {stateName.error && <div className="form-error">{stateName.error}</div>}
67
+ </div>
68
+ </div>
69
+
70
+ <div className="form-group">
71
+ <label className="form-label">Address</label>
72
+ <input
73
+ className={`form-input ${address.error ? 'error' : ''}`}
74
+ value={address.value}
75
+ onChange={e => model.setAddress(e.target.value)}
76
+ />
77
+ {address.error && <div className="form-error">{address.error}</div>}
78
+ </div>
79
+
80
+ <div className="form-group">
81
+ <label className="form-label">Capacity</label>
82
+ <input
83
+ className={`form-input ${capacity.error ? 'error' : ''}`}
84
+ type="number"
85
+ value={capacity.value}
86
+ onChange={e => model.setCapacity(Number(e.target.value))}
87
+ />
88
+ {capacity.error && <div className="form-error">{capacity.error}</div>}
89
+ </div>
90
+
91
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '1rem' }}>
92
+ <button
93
+ type="submit"
94
+ className="btn btn-primary"
95
+ disabled={!model.valid || !model.dirty || saving}
96
+ >
97
+ {saving ? 'Saving...' : 'Save Changes'}
98
+ </button>
99
+ {model.dirty && <span className="dirty-indicator">Unsaved changes</span>}
100
+ {model.dirty && (
101
+ <button
102
+ type="button"
103
+ className="btn btn-secondary btn-sm"
104
+ onClick={() => model.rollback()}
105
+ >
106
+ Discard
107
+ </button>
108
+ )}
109
+ </div>
110
+ </form>
111
+ );
112
+ }
@@ -0,0 +1,81 @@
1
+ import { useParams, Link } from 'react-router-dom';
2
+ import { useLocal, useEvent } from 'mvc-kit/react';
3
+ import { LocationProfileViewModel } from '../../viewmodels/LocationProfileViewModel';
4
+ import { LocationForm } from './LocationForm';
5
+ import { Spinner } from '../shared/Spinner';
6
+ import { ErrorBanner } from '../shared/ErrorBanner';
7
+
8
+ function LocationProfileContent({ locationId }: { locationId: string }) {
9
+ const [state, vm] = useLocal(LocationProfileViewModel, {
10
+ location: null,
11
+ locationId,
12
+ });
13
+ const loadState = vm.async.load;
14
+ const saveState = vm.async.save;
15
+
16
+ useEvent(vm, 'saved', () => {
17
+ // Toast is handled in the ViewModel via AppEventBus
18
+ });
19
+
20
+ if (loadState.loading) return <Spinner large />;
21
+ if (loadState.error) return <ErrorBanner message={loadState.error} />;
22
+ if (!state.location || !vm.model) return null;
23
+
24
+ return (
25
+ <div>
26
+ <Link to="/locations" className="back-link">
27
+ &larr; Back to Locations
28
+ </Link>
29
+ <h1 className="page-title">{state.location.name}</h1>
30
+
31
+ <div className="profile-layout">
32
+ <div>
33
+ <div className="card">
34
+ <h3 className="profile-section-title">Details</h3>
35
+ <div className="detail-row">
36
+ <span className="detail-label">Type</span>
37
+ <span>{state.location.type}</span>
38
+ </div>
39
+ <div className="detail-row">
40
+ <span className="detail-label">Status</span>
41
+ <span className={`badge badge-${state.location.status}`}>
42
+ {state.location.status}
43
+ </span>
44
+ </div>
45
+ <div className="detail-row">
46
+ <span className="detail-label">City</span>
47
+ <span>{state.location.city}, {state.location.state}</span>
48
+ </div>
49
+ <div className="detail-row">
50
+ <span className="detail-label">Manager</span>
51
+ <span>{vm.managerName}</span>
52
+ </div>
53
+ <div className="detail-row">
54
+ <span className="detail-label">Capacity</span>
55
+ <span>{state.location.capacity}</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div>
61
+ <div className="card">
62
+ <h3 className="profile-section-title">Edit Location</h3>
63
+ {saveState.error && <ErrorBanner message={saveState.error} />}
64
+ <LocationForm
65
+ model={vm.model}
66
+ onSave={() => vm.save()}
67
+ saving={saveState.loading}
68
+ />
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function LocationProfilePage() {
77
+ const { id } = useParams<{ id: string }>();
78
+ if (!id) return null;
79
+ // key={id} remounts when navigating between different locations
80
+ return <LocationProfileContent key={id} locationId={id} />;
81
+ }
@@ -0,0 +1,127 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { useLocal, DataTable } from 'mvc-kit/react';
3
+ import type { Column } from 'mvc-kit/react';
4
+ import { LocationsViewModel } from '../../viewmodels/LocationsViewModel';
5
+ import { LocationFilters } from './LocationFilters';
6
+ import { Spinner } from '../shared/Spinner';
7
+ import { ErrorBanner } from '../shared/ErrorBanner';
8
+ import type { LocationState } from '../../types/location';
9
+
10
+ const columns: Column<LocationState>[] = [
11
+ {
12
+ key: 'name',
13
+ header: 'Name',
14
+ render: loc => <span style={{ fontWeight: 500 }}>{loc.name}</span>,
15
+ sortable: true,
16
+ },
17
+ {
18
+ key: 'type',
19
+ header: 'Type',
20
+ render: loc => (
21
+ <span className={`badge badge-${loc.type === 'office' ? 'admin' : loc.type === 'warehouse' ? 'manager' : 'member'}`}>
22
+ {loc.type}
23
+ </span>
24
+ ),
25
+ },
26
+ {
27
+ key: 'city',
28
+ header: 'City',
29
+ render: loc => `${loc.city}, ${loc.state}`,
30
+ sortable: true,
31
+ },
32
+ {
33
+ key: 'status',
34
+ header: 'Status',
35
+ render: loc => <span className={`badge badge-${loc.status}`}>{loc.status}</span>,
36
+ sortable: true,
37
+ },
38
+ {
39
+ key: 'capacity',
40
+ header: 'Capacity',
41
+ render: loc => loc.capacity,
42
+ sortable: true,
43
+ },
44
+ {
45
+ key: 'actions',
46
+ header: '',
47
+ render: loc => (
48
+ <Link to={`/locations/${loc.id}`} className="link">
49
+ View
50
+ </Link>
51
+ ),
52
+ },
53
+ ];
54
+
55
+ export function LocationsPage() {
56
+ const [state, vm] = useLocal(LocationsViewModel, {
57
+ search: '',
58
+ typeFilter: 'all',
59
+ statusFilter: 'all',
60
+ });
61
+ const { loading, error } = vm.async.load;
62
+
63
+ return (
64
+ <div>
65
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
66
+ <h1 className="page-title" style={{ marginBottom: 0 }}>Locations</h1>
67
+ <button className="btn btn-secondary" onClick={() => vm.refresh()}>
68
+ Refresh
69
+ </button>
70
+ </div>
71
+ <div style={{ marginBottom: '1.5rem' }} />
72
+
73
+ <LocationFilters
74
+ search={state.search}
75
+ typeFilter={state.typeFilter}
76
+ statusFilter={state.statusFilter}
77
+ onSearchChange={v => vm.setSearch(v)}
78
+ onTypeFilterChange={v => vm.setTypeFilter(v)}
79
+ onStatusFilterChange={v => vm.setStatusFilter(v)}
80
+ />
81
+
82
+ <div className="results-bar">
83
+ <span>Showing {vm.filteredCount} of {vm.total}</span>
84
+ </div>
85
+
86
+ {vm.selection.hasSelection && (
87
+ <div className="selection-bar">
88
+ <span>{vm.selection.count} selected</span>
89
+ <button
90
+ className="btn btn-primary btn-sm"
91
+ onClick={() => vm.bulkToggleStatus()}
92
+ disabled={vm.async.bulkToggleStatus.loading}
93
+ >
94
+ {vm.async.bulkToggleStatus.loading ? 'Toggling...' : 'Toggle Status'}
95
+ </button>
96
+ </div>
97
+ )}
98
+
99
+ <DataTable
100
+ items={vm.paged}
101
+ columns={columns}
102
+ loading={loading}
103
+ error={error}
104
+ sort={vm.sorting}
105
+ selection={vm.selection}
106
+ pagination={vm.pagination}
107
+ paginationTotal={vm.filteredCount}
108
+ renderLoading={() => <Spinner />}
109
+ renderError={msg => <ErrorBanner message={msg} />}
110
+ renderEmpty={() => <div className="empty-state">No locations match your filters.</div>}
111
+ renderSortIndicator={({ active, direction }) => (
112
+ <span>{active ? (direction === 'asc' ? ' ↑' : ' ↓') : ''}</span>
113
+ )}
114
+ renderPagination={info => (
115
+ <div className="pagination-bar">
116
+ <button disabled={!info.hasPrev} onClick={info.goPrev}>Prev</button>
117
+ <span>Page {info.page} of {info.pageCount}</span>
118
+ <button disabled={!info.hasNext} onClick={info.goNext}>Next</button>
119
+ </div>
120
+ )}
121
+ className="table-container"
122
+ />
123
+
124
+ {vm.isEmpty && <div className="empty-state">No locations match your filters.</div>}
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,59 @@
1
+ import { CardList } from 'mvc-kit/react';
2
+ import type { ConversationDisplay } from '../../viewmodels/ConversationsViewModel';
3
+
4
+ interface ConversationListProps {
5
+ conversations: ConversationDisplay[];
6
+ selectedId: string | null;
7
+ onSelect: (id: string) => void;
8
+ search: string;
9
+ onSearchChange: (value: string) => void;
10
+ }
11
+
12
+ export function ConversationList({
13
+ conversations,
14
+ selectedId,
15
+ onSelect,
16
+ search,
17
+ onSearchChange,
18
+ }: ConversationListProps) {
19
+ return (
20
+ <div className="conversation-list">
21
+ <div className="conversation-list-header">
22
+ <input
23
+ className="filter-input"
24
+ style={{ width: '100%', minWidth: 0 }}
25
+ type="text"
26
+ value={search}
27
+ onChange={e => onSearchChange(e.target.value)}
28
+ placeholder="Search conversations..."
29
+ />
30
+ </div>
31
+ <div className="conversation-list-items">
32
+ <CardList
33
+ items={conversations}
34
+ keyOf={c => c.id}
35
+ renderItem={conv => (
36
+ <div
37
+ className={`conversation-item ${conv.id === selectedId ? 'selected' : ''}`}
38
+ onClick={() => onSelect(conv.id)}
39
+ >
40
+ <div className="conversation-item-header">
41
+ <span className="conversation-item-name">{conv.displayName}</span>
42
+ {conv.unreadCount > 0 && (
43
+ <span className="conversation-unread">{conv.unreadCount}</span>
44
+ )}
45
+ </div>
46
+ <div className="conversation-item-preview">{conv.lastMessage}</div>
47
+ </div>
48
+ )}
49
+ renderEmpty={() => (
50
+ <div className="empty-state" style={{ padding: '1.5rem' }}>
51
+ No conversations found.
52
+ </div>
53
+ )}
54
+ className="conversation-card-list"
55
+ />
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,22 @@
1
+ import type { MessageState } from '../../types/message';
2
+
3
+ interface MessageBubbleProps {
4
+ message: MessageState;
5
+ isMine: boolean;
6
+ senderName: string;
7
+ }
8
+
9
+ export function MessageBubble({ message, isMine, senderName }: MessageBubbleProps) {
10
+ const time = new Date(message.sentAt).toLocaleTimeString([], {
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ });
14
+
15
+ return (
16
+ <div className={`message-bubble ${isMine ? 'mine' : 'theirs'}`}>
17
+ {!isMine && <div className="message-sender">{senderName}</div>}
18
+ <div>{message.text}</div>
19
+ <div className="message-time">{time}</div>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useLocal, useEvent, InfiniteScroll } from 'mvc-kit/react';
3
+ import { singleton } from 'mvc-kit';
4
+ import { MessageThreadViewModel } from '../../viewmodels/MessageThreadViewModel';
5
+ import { UsersResource } from '../../resources/UsersResource';
6
+ import { MessageBubble } from './MessageBubble';
7
+ import { Spinner } from '../shared/Spinner';
8
+
9
+ interface MessageThreadProps {
10
+ conversationId: string;
11
+ currentUserId: string;
12
+ }
13
+
14
+ export function MessageThread({ conversationId, currentUserId }: MessageThreadProps) {
15
+ const [state, vm] = useLocal(MessageThreadViewModel, { draft: '' });
16
+ const { loading } = vm.async.loadConversation;
17
+ const loadingMore = vm.async.loadOlderMessages.loading;
18
+ const messagesRef = useRef<HTMLDivElement>(null);
19
+
20
+ // Load messages when conversationId changes (prop-driven, uses AbortSignal.any for cancellation)
21
+ useEffect(() => {
22
+ vm.loadConversation(conversationId);
23
+ }, [conversationId, vm]);
24
+
25
+ // Scroll to bottom when new message is sent
26
+ useEvent(vm, 'messageSent', () => {
27
+ setTimeout(() => {
28
+ if (messagesRef.current) {
29
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
30
+ }
31
+ }, 50);
32
+ });
33
+
34
+ // Resolve sender names from UsersResource
35
+ const users = singleton(UsersResource);
36
+ const getSenderName = (senderId: string): string => {
37
+ const user = users.get(senderId);
38
+ return user ? `${user.firstName} ${user.lastName}` : 'Unknown';
39
+ };
40
+
41
+ const handleSend = (e: React.FormEvent) => {
42
+ e.preventDefault();
43
+ vm.sendMessage(conversationId);
44
+ };
45
+
46
+ const messages = vm.sortedMessages;
47
+
48
+ return (
49
+ <div className="message-thread">
50
+ <div className="message-thread-header">
51
+ Messages
52
+ </div>
53
+
54
+ <div
55
+ className="message-thread-messages"
56
+ ref={messagesRef}
57
+ >
58
+ {loading && <Spinner />}
59
+ {!loading && (
60
+ <InfiniteScroll
61
+ hasMore={vm.feed.hasMore}
62
+ loading={loadingMore}
63
+ onLoadMore={() => vm.loadOlderMessages()}
64
+ direction="up"
65
+ renderLoading={() => (
66
+ <div className="spinner-center"><div className="spinner" /></div>
67
+ )}
68
+ renderEnd={() => (
69
+ <div className="feed-end">Beginning of conversation</div>
70
+ )}
71
+ >
72
+ {messages.map(msg => (
73
+ <MessageBubble
74
+ key={msg.id}
75
+ message={msg}
76
+ isMine={msg.senderId === currentUserId}
77
+ senderName={getSenderName(msg.senderId)}
78
+ />
79
+ ))}
80
+ </InfiniteScroll>
81
+ )}
82
+ {!loading && messages.length === 0 && (
83
+ <div className="empty-state">No messages yet.</div>
84
+ )}
85
+ </div>
86
+
87
+ <form className="message-compose" onSubmit={handleSend}>
88
+ <input
89
+ type="text"
90
+ value={state.draft}
91
+ onChange={e => vm.setDraft(e.target.value)}
92
+ placeholder="Type a message..."
93
+ />
94
+ <button type="submit" className="btn btn-primary" disabled={!vm.canSend}>
95
+ Send
96
+ </button>
97
+ </form>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,52 @@
1
+ import { useLocal, useSingleton } from 'mvc-kit/react';
2
+ import { ConversationsViewModel } from '../../viewmodels/ConversationsViewModel';
3
+ import { AuthViewModel } from '../../viewmodels/AuthViewModel';
4
+ import { ConversationList } from './ConversationList';
5
+ import { MessageThread } from './MessageThread';
6
+ import { Spinner } from '../shared/Spinner';
7
+ import { ErrorBanner } from '../shared/ErrorBanner';
8
+
9
+ export function MessagingPage() {
10
+ const [authState] = useSingleton(AuthViewModel);
11
+ const [state, vm] = useLocal(ConversationsViewModel, {
12
+ search: '',
13
+ selectedId: null,
14
+ currentUserId: '',
15
+ });
16
+ const { loading, error } = vm.async.load;
17
+
18
+ const currentUserId = authState.user?.id ?? '';
19
+
20
+ return (
21
+ <div>
22
+ <h1 className="page-title">Messaging</h1>
23
+
24
+ {error && <ErrorBanner message={error} />}
25
+
26
+ <div className="messaging-layout">
27
+ {loading ? (
28
+ <Spinner />
29
+ ) : (
30
+ <ConversationList
31
+ conversations={vm.filtered}
32
+ selectedId={state.selectedId}
33
+ onSelect={id => vm.selectConversation(id)}
34
+ search={state.search}
35
+ onSearchChange={v => vm.setSearch(v)}
36
+ />
37
+ )}
38
+
39
+ {state.selectedId ? (
40
+ <MessageThread
41
+ conversationId={state.selectedId}
42
+ currentUserId={currentUserId}
43
+ />
44
+ ) : (
45
+ <div className="message-thread-empty">
46
+ Select a conversation to start messaging
47
+ </div>
48
+ )}
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,3 @@
1
+ export function ErrorBanner({ message }: { message: string }) {
2
+ return <div className="error-banner">{message}</div>;
3
+ }
@@ -0,0 +1,7 @@
1
+ export function Spinner({ large }: { large?: boolean }) {
2
+ return (
3
+ <div className="spinner-center">
4
+ <div className={`spinner ${large ? 'spinner-lg' : ''}`} />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,41 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useSingleton } from 'mvc-kit/react';
3
+ import { useEvent } from 'mvc-kit/react';
4
+ import { AppEventBus } from '../../events/AppEventBus';
5
+
6
+ interface ToastItem {
7
+ id: number;
8
+ message: string;
9
+ severity: 'success' | 'error' | 'info';
10
+ }
11
+
12
+ let nextId = 0;
13
+
14
+ export function Toast() {
15
+ const bus = useSingleton(AppEventBus);
16
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
17
+
18
+ const addToast = useCallback((item: Omit<ToastItem, 'id'>) => {
19
+ const id = nextId++;
20
+ setToasts(prev => [...prev, { ...item, id }]);
21
+ setTimeout(() => {
22
+ setToasts(prev => prev.filter(t => t.id !== id));
23
+ }, 3000);
24
+ }, []);
25
+
26
+ useEvent(bus, 'toast:show', ({ message, severity }) => {
27
+ addToast({ message, severity });
28
+ });
29
+
30
+ if (toasts.length === 0) return null;
31
+
32
+ return (
33
+ <div className="toast-container">
34
+ {toasts.map(t => (
35
+ <div key={t.id} className={`toast toast-${t.severity}`}>
36
+ {t.message}
37
+ </div>
38
+ ))}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,59 @@
1
+ import type { UserState } from '../../types/user';
2
+
3
+ interface UserFiltersProps {
4
+ search: string;
5
+ roleFilter: 'all' | UserState['role'];
6
+ statusFilter: 'all' | UserState['status'];
7
+ onSearchChange: (value: string) => void;
8
+ onRoleFilterChange: (value: 'all' | UserState['role']) => void;
9
+ onStatusFilterChange: (value: 'all' | UserState['status']) => void;
10
+ }
11
+
12
+ export function UserFilters({
13
+ search,
14
+ roleFilter,
15
+ statusFilter,
16
+ onSearchChange,
17
+ onRoleFilterChange,
18
+ onStatusFilterChange,
19
+ }: UserFiltersProps) {
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 by name or email..."
30
+ />
31
+ </div>
32
+ <div className="filter-group">
33
+ <label className="filter-label">Role</label>
34
+ <select
35
+ className="filter-select"
36
+ value={roleFilter}
37
+ onChange={e => onRoleFilterChange(e.target.value as 'all' | UserState['role'])}
38
+ >
39
+ <option value="all">All Roles</option>
40
+ <option value="admin">Admin</option>
41
+ <option value="manager">Manager</option>
42
+ <option value="member">Member</option>
43
+ </select>
44
+ </div>
45
+ <div className="filter-group">
46
+ <label className="filter-label">Status</label>
47
+ <select
48
+ className="filter-select"
49
+ value={statusFilter}
50
+ onChange={e => onStatusFilterChange(e.target.value as 'all' | UserState['status'])}
51
+ >
52
+ <option value="all">All Statuses</option>
53
+ <option value="active">Active</option>
54
+ <option value="inactive">Inactive</option>
55
+ </select>
56
+ </div>
57
+ </div>
58
+ );
59
+ }