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,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
|
+
← 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,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
|
+
}
|