@punch-in/strapi-admin 1.2.16 → 1.2.17

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 (35) hide show
  1. package/admin/src/components/GlobalStyle/index.js +19 -0
  2. package/admin/src/components/LeftMenu/LeftMenuLinkHeader/Title.js +1 -0
  3. package/admin/src/components/LeftMenu/LeftMenuLinkSection/LeftMenuListLink.js +8 -1
  4. package/admin/src/components/LeftMenu/LinksContainer/index.js +2 -0
  5. package/admin/src/containers/HomePage/PunchInToday.js +1 -40
  6. package/admin/src/containers/HomePage/RollCall.js +355 -0
  7. package/admin/src/containers/HomePage/VisitorApprovals.js +295 -0
  8. package/admin/src/containers/HomePage/components.js +122 -1
  9. package/admin/src/containers/HomePage/index.js +4 -0
  10. package/admin/src/containers/HomePage/punchUtils.js +42 -0
  11. package/admin/src/translations/en.json +18 -1
  12. package/package.json +1 -1
  13. package/strapi-admin/node_modules/is-wsl/index.d.ts +0 -15
  14. package/strapi-admin/node_modules/is-wsl/index.js +0 -31
  15. package/strapi-admin/node_modules/is-wsl/license +0 -9
  16. package/strapi-admin/node_modules/is-wsl/package.json +0 -77
  17. package/strapi-admin/node_modules/is-wsl/readme.md +0 -36
  18. package/strapi-admin/node_modules/qs/.editorconfig +0 -39
  19. package/strapi-admin/node_modules/qs/.eslintignore +0 -2
  20. package/strapi-admin/node_modules/qs/.eslintrc +0 -35
  21. package/strapi-admin/node_modules/qs/.github/FUNDING.yml +0 -12
  22. package/strapi-admin/node_modules/qs/.nycrc +0 -13
  23. package/strapi-admin/node_modules/qs/CHANGELOG.md +0 -372
  24. package/strapi-admin/node_modules/qs/LICENSE.md +0 -29
  25. package/strapi-admin/node_modules/qs/README.md +0 -611
  26. package/strapi-admin/node_modules/qs/dist/qs.js +0 -1903
  27. package/strapi-admin/node_modules/qs/lib/formats.js +0 -23
  28. package/strapi-admin/node_modules/qs/lib/index.js +0 -11
  29. package/strapi-admin/node_modules/qs/lib/parse.js +0 -263
  30. package/strapi-admin/node_modules/qs/lib/stringify.js +0 -290
  31. package/strapi-admin/node_modules/qs/lib/utils.js +0 -251
  32. package/strapi-admin/node_modules/qs/package.json +0 -100
  33. package/strapi-admin/node_modules/qs/test/parse.js +0 -781
  34. package/strapi-admin/node_modules/qs/test/stringify.js +0 -848
  35. package/strapi-admin/node_modules/qs/test/utils.js +0 -136
@@ -173,6 +173,25 @@ const GlobalStyle = createGlobalStyle`
173
173
  a::-moz-focus-inner {
174
174
  border: 0;
175
175
  }
176
+
177
+ /*
178
+ * Date picker (react-dates, styled by @punch-in/buffet-modern-styles).
179
+ * That package colours the weekday names with its near-white "greyIconBkgd"
180
+ * (#F6F6F9, invisible on a white popup) and shortens the month caption, so the
181
+ * absolutely positioned weekday header (z-index 2) sits on top of the first row of
182
+ * dates and swallows their clicks. Fix both here until the package is fixed.
183
+ */
184
+ .DayPicker_weekHeader {
185
+ pointer-events: none;
186
+ }
187
+
188
+ .DayPicker_weekHeader li small {
189
+ color: #666687 !important;
190
+ }
191
+
192
+ .CalendarMonth_caption {
193
+ padding-bottom: 52px !important;
194
+ }
176
195
  `;
177
196
 
178
197
  export default GlobalStyle;
@@ -12,6 +12,7 @@ const Title = styled.div`
12
12
  font-size: 1.1rem;
13
13
  letter-spacing: 0.1rem;
14
14
  font-weight: 800;
15
+ flex-shrink: 0;
15
16
  max-height: 26px;
16
17
  overflow: hidden;
17
18
  `;
@@ -1,7 +1,14 @@
1
1
  import styled from 'styled-components';
2
2
 
3
3
  const LeftMenuListLink = styled.div`
4
- max-height: 300px;
4
+ /* No fixed height: sections share the menu's height and each scrolls
5
+ on its own, so later sections are never pushed below the viewport. */
6
+ flex: 0 1 auto;
7
+ min-height: 0;
8
+ /* The Plugins (modules) section never shrinks, so all its items stay
9
+ visible; the long collection/single type lists give up space instead.
10
+ The cap only kicks in on very small screens, where it scrolls. */
11
+ ${({ section }) => (section === 'plugins' ? 'flex-shrink: 0; max-height: 60%;' : '')}
5
12
  margin-bottom: 19px;
6
13
  overflow-y: hidden;
7
14
  width: 22rem;
@@ -7,6 +7,8 @@ const LinksContainer = styled.div`
7
7
  top: ${props => props.theme.main.sizes.leftMenu.height};
8
8
  z-index: 20;
9
9
  overflow: hidden;
10
+ display: flex;
11
+ flex-direction: column;
10
12
  right: 0;
11
13
  bottom: 0;
12
14
  left: 0;
@@ -12,10 +12,8 @@ import {
12
12
  faCheckCircle,
13
13
  faClock,
14
14
  } from '@fortawesome/free-solid-svg-icons';
15
- import moment from 'moment';
16
15
  import { Block, SiteStrip, SiteTab, SiteNav, SiteNavBtn, PunchList, PunchRow, PunchMeta, PunchName, PunchTimes, PunchAction, EmptyPunch, StatusPill, ApproveBtn } from './components';
17
-
18
- const NONE_SITE = 'none';
16
+ import { NONE_SITE, displayName, clockLabel, siteIdOf, siteNameOf } from './punchUtils';
19
17
 
20
18
  const startOfTodayIso = () => {
21
19
  const d = new Date();
@@ -23,43 +21,6 @@ const startOfTodayIso = () => {
23
21
  return d.toISOString();
24
22
  };
25
23
 
26
- const displayName = (user) => {
27
- if (!user) {
28
- return 'Unknown employee';
29
- }
30
- const last = user.lastName || '';
31
- const first = user.firstName || '';
32
- if (last && first) {
33
- return last + ', ' + first;
34
- }
35
- return last || first || user.email || user.username || 'Employee';
36
- };
37
-
38
- const clockLabel = (value) => {
39
- if (!value) {
40
- return null;
41
- }
42
- const parsed = moment(value);
43
- return parsed.isValid() ? parsed.format('HH:mm') : null;
44
- };
45
-
46
- const siteIdOf = (site) => {
47
- if (!site) {
48
- return NONE_SITE;
49
- }
50
- if (typeof site === 'object') {
51
- return site.id != null ? String(site.id) : NONE_SITE;
52
- }
53
- return String(site);
54
- };
55
-
56
- const siteNameOf = (site) => {
57
- if (site && typeof site === 'object' && site.name) {
58
- return site.name;
59
- }
60
- return 'No site';
61
- };
62
-
63
24
  const PunchInToday = () => {
64
25
  const stripRef = useRef(null);
65
26
  const [loading, setLoading] = useState(true);
@@ -0,0 +1,355 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { FormattedMessage } from 'react-intl';
3
+ import { request } from 'strapi-helper-plugin';
4
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5
+ import {
6
+ faClipboardCheck,
7
+ faCheck,
8
+ faMapMarkerAlt,
9
+ faSignInAlt,
10
+ faSignOutAlt,
11
+ faSyncAlt,
12
+ } from '@fortawesome/free-solid-svg-icons';
13
+ import moment from 'moment';
14
+ import {
15
+ Block,
16
+ SiteStrip,
17
+ SiteTab,
18
+ SiteNav,
19
+ PunchList,
20
+ PunchRow,
21
+ PunchMeta,
22
+ PunchName,
23
+ PunchTimes,
24
+ PunchAction,
25
+ EmptyPunch,
26
+ StatusPill,
27
+ RollCallButton,
28
+ RollCallHeader,
29
+ RollCallSummary,
30
+ RefreshBtn,
31
+ VisitorBadge,
32
+ CheckToggle,
33
+ CheckedTag,
34
+ UndoBtn,
35
+ } from './components';
36
+ import { displayName, clockLabel, siteIdOf, siteNameOf } from './punchUtils';
37
+
38
+ // Site roll call: everyone clocked in (employees and visitors) who hasn't
39
+ // clocked out, or clocked out within the last hour - see the server's
40
+ // time-sheet onSite controller. The officer ticks people off as they're
41
+ // accounted for.
42
+ //
43
+ // Ticks are kept in this browser only (localStorage, keyed by time-sheet id)
44
+ // so a refresh mid-evacuation doesn't lose them; they are not shared with
45
+ // other admins or the tablet.
46
+
47
+ const ALL_SITES = 'all';
48
+ const REFRESH_MS = 60 * 1000;
49
+ const CHECKS_KEY = 'punchin.rollCall.checks';
50
+ // Ticks older than this are dropped on load - a roll call is a same-day job.
51
+ const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
52
+
53
+ const loadChecks = () => {
54
+ try {
55
+ const raw = JSON.parse(window.localStorage.getItem(CHECKS_KEY) || '{}');
56
+ const cutoff = Date.now() - CHECK_TTL_MS;
57
+ return Object.keys(raw).reduce((acc, id) => {
58
+ if (typeof raw[id] === 'number' && raw[id] >= cutoff) {
59
+ acc[id] = raw[id];
60
+ }
61
+ return acc;
62
+ }, {});
63
+ } catch (err) {
64
+ return {};
65
+ }
66
+ };
67
+
68
+ const saveChecks = (checks) => {
69
+ try {
70
+ window.localStorage.setItem(CHECKS_KEY, JSON.stringify(checks));
71
+ } catch (err) {
72
+ // Storage unavailable (private window, quota) - ticks still work for this page view.
73
+ }
74
+ };
75
+
76
+ // Clock-in time, with the date when it wasn't today so a forgotten clock-out
77
+ // from an earlier day stands out instead of reading as "on site now".
78
+ const clockInLabel = (value) => {
79
+ const parsed = moment(value);
80
+ if (!parsed.isValid()) {
81
+ return '—';
82
+ }
83
+ return parsed.isSame(moment(), 'day') ? parsed.format('HH:mm') : parsed.format('D MMM HH:mm');
84
+ };
85
+
86
+ const RollCall = () => {
87
+ const [open, setOpen] = useState(false);
88
+ const [loading, setLoading] = useState(false);
89
+ const [error, setError] = useState(null);
90
+ const [sheets, setSheets] = useState([]);
91
+ const [loadedAt, setLoadedAt] = useState(null);
92
+ const [selectedSite, setSelectedSite] = useState(ALL_SITES);
93
+ const [checks, setChecks] = useState(loadChecks);
94
+
95
+ const load = useCallback(async () => {
96
+ setLoading(true);
97
+ setError(null);
98
+ try {
99
+ const list = await request('/time-sheets/on-site', { method: 'GET' });
100
+ setSheets(Array.isArray(list) ? list : []);
101
+ setLoadedAt(new Date());
102
+ } catch (err) {
103
+ setError('Could not load the roll call. Check your connection and try again.');
104
+ } finally {
105
+ setLoading(false);
106
+ }
107
+ }, []);
108
+
109
+ useEffect(() => {
110
+ if (!open) {
111
+ return undefined;
112
+ }
113
+ load();
114
+ const timer = setInterval(load, REFRESH_MS);
115
+ return () => clearInterval(timer);
116
+ }, [open, load]);
117
+
118
+ const setChecked = (id, isChecked) => {
119
+ setChecks((prev) => {
120
+ const next = Object.assign({}, prev);
121
+ if (isChecked) {
122
+ next[id] = Date.now();
123
+ } else {
124
+ delete next[id];
125
+ }
126
+ saveChecks(next);
127
+ return next;
128
+ });
129
+ };
130
+
131
+ const sites = useMemo(() => {
132
+ const map = {};
133
+ sheets.forEach((sheet) => {
134
+ const id = siteIdOf(sheet.site);
135
+ if (!map[id]) {
136
+ map[id] = { id: id, name: siteNameOf(sheet.site), count: 0 };
137
+ }
138
+ map[id].count += 1;
139
+ });
140
+ return Object.keys(map)
141
+ .map((key) => map[key])
142
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
143
+ }, [sheets]);
144
+
145
+ useEffect(() => {
146
+ if (selectedSite !== ALL_SITES && !sites.some((site) => site.id === selectedSite)) {
147
+ setSelectedSite(ALL_SITES);
148
+ }
149
+ }, [sites, selectedSite]);
150
+
151
+ // Still on site first, then those who've left; alphabetical within each.
152
+ // Order doesn't change when someone is ticked, so rows don't jump under
153
+ // the officer's cursor.
154
+ const rows = useMemo(
155
+ () =>
156
+ sheets
157
+ .filter((sheet) => selectedSite === ALL_SITES || siteIdOf(sheet.site) === selectedSite)
158
+ .slice()
159
+ .sort((a, b) => {
160
+ if (!a.until !== !b.until) {
161
+ return a.until ? 1 : -1;
162
+ }
163
+ return String(displayName(a.users)).localeCompare(String(displayName(b.users)));
164
+ }),
165
+ [sheets, selectedSite]
166
+ );
167
+
168
+ const onSiteCount = rows.filter((sheet) => !sheet.until).length;
169
+ const leftCount = rows.length - onSiteCount;
170
+ const checkedCount = rows.filter((sheet) => checks[sheet.id]).length;
171
+
172
+ return (
173
+ <>
174
+ <RollCallButton type="button" $open={open} aria-expanded={open} onClick={() => setOpen(!open)}>
175
+ <FontAwesomeIcon icon={faClipboardCheck} />
176
+ {open ? (
177
+ <FormattedMessage id="HomePage.rollCall.hide" defaultMessage="Hide site roll call" />
178
+ ) : (
179
+ <FormattedMessage id="HomePage.rollCall.button" defaultMessage="Site roll call" />
180
+ )}
181
+ </RollCallButton>
182
+
183
+ {open && (
184
+ <Block>
185
+ <RollCallHeader>
186
+ <div>
187
+ <h2>
188
+ <FontAwesomeIcon icon={faClipboardCheck} style={{ marginRight: 10 }} />
189
+ <FormattedMessage id="HomePage.rollCall.title" defaultMessage="Site roll call" />
190
+ </h2>
191
+ <p style={{ marginTop: 8, color: '#5c5f66', fontSize: 14 }}>
192
+ <FormattedMessage
193
+ id="HomePage.rollCall.subtitle"
194
+ defaultMessage="Everyone clocked in, including visitors, plus anyone who clocked out in the last hour."
195
+ />
196
+ </p>
197
+ </div>
198
+ <RefreshBtn type="button" onClick={load} disabled={loading}>
199
+ <FontAwesomeIcon icon={faSyncAlt} spin={loading} />
200
+ {loadedAt ? (
201
+ <FormattedMessage
202
+ id="HomePage.rollCall.updated"
203
+ defaultMessage="Updated {time}"
204
+ values={{ time: moment(loadedAt).format('HH:mm') }}
205
+ />
206
+ ) : (
207
+ <FormattedMessage id="HomePage.rollCall.refresh" defaultMessage="Refresh" />
208
+ )}
209
+ </RefreshBtn>
210
+ </RollCallHeader>
211
+
212
+ {error && <EmptyPunch>{error}</EmptyPunch>}
213
+
214
+ {!error && loading && !loadedAt && (
215
+ <EmptyPunch>
216
+ <FormattedMessage id="HomePage.rollCall.loading" defaultMessage="Loading roll call…" />
217
+ </EmptyPunch>
218
+ )}
219
+
220
+ {!error && loadedAt && (
221
+ <>
222
+ <RollCallSummary>
223
+ <span>
224
+ <FormattedMessage
225
+ id="HomePage.rollCall.onSite"
226
+ defaultMessage="{count} on site"
227
+ values={{ count: <b>{onSiteCount}</b> }}
228
+ />
229
+ </span>
230
+ <span>
231
+ <FormattedMessage
232
+ id="HomePage.rollCall.left"
233
+ defaultMessage="{count} left in the last hour"
234
+ values={{ count: <b>{leftCount}</b> }}
235
+ />
236
+ </span>
237
+ <span>
238
+ <FormattedMessage
239
+ id="HomePage.rollCall.checkedCount"
240
+ defaultMessage="{checked} of {total} checked"
241
+ values={{ checked: <b>{checkedCount}</b>, total: rows.length }}
242
+ />
243
+ </span>
244
+ </RollCallSummary>
245
+
246
+ {sites.length > 1 && (
247
+ <SiteNav>
248
+ <SiteStrip>
249
+ <SiteTab
250
+ type="button"
251
+ $active={selectedSite === ALL_SITES}
252
+ onClick={() => setSelectedSite(ALL_SITES)}
253
+ >
254
+ <FormattedMessage id="HomePage.rollCall.allSites" defaultMessage="All sites" />
255
+ <em>{sheets.length}</em>
256
+ </SiteTab>
257
+ {sites.map((site) => (
258
+ <SiteTab
259
+ key={site.id}
260
+ type="button"
261
+ $active={site.id === selectedSite}
262
+ onClick={() => setSelectedSite(site.id)}
263
+ >
264
+ <FontAwesomeIcon icon={faMapMarkerAlt} />
265
+ <span>{site.name}</span>
266
+ <em>{site.count}</em>
267
+ </SiteTab>
268
+ ))}
269
+ </SiteStrip>
270
+ </SiteNav>
271
+ )}
272
+
273
+ {rows.length === 0 ? (
274
+ <EmptyPunch>
275
+ <FormattedMessage
276
+ id="HomePage.rollCall.empty"
277
+ defaultMessage="No one is clocked in, and no one has clocked out in the last hour."
278
+ />
279
+ </EmptyPunch>
280
+ ) : (
281
+ <PunchList style={{ maxHeight: 520 }}>
282
+ {rows.map((sheet) => {
283
+ const isChecked = Boolean(checks[sheet.id]);
284
+ return (
285
+ <PunchRow key={sheet.id}>
286
+ <PunchMeta>
287
+ <PunchName>
288
+ {displayName(sheet.users)}
289
+ {sheet.isVisitor && (
290
+ <VisitorBadge>
291
+ <FormattedMessage id="HomePage.rollCall.visitor" defaultMessage="Visitor" />
292
+ </VisitorBadge>
293
+ )}
294
+ </PunchName>
295
+ <PunchTimes>
296
+ <span>
297
+ <FontAwesomeIcon icon={faSignInAlt} /> {clockInLabel(sheet.from)}
298
+ </span>
299
+ {sheet.until ? (
300
+ <span>
301
+ <FontAwesomeIcon icon={faSignOutAlt} />
302
+ <FormattedMessage
303
+ id="HomePage.rollCall.leftAt"
304
+ defaultMessage="Left at {time}"
305
+ values={{ time: clockLabel(sheet.until) || '—' }}
306
+ />
307
+ </span>
308
+ ) : (
309
+ <StatusPill style={{ height: 18, padding: '0 8px', fontSize: 11 }}>
310
+ <FormattedMessage id="HomePage.rollCall.stillOnSite" defaultMessage="On site" />
311
+ </StatusPill>
312
+ )}
313
+ {selectedSite === ALL_SITES && sites.length > 1 && (
314
+ <span>
315
+ <FontAwesomeIcon icon={faMapMarkerAlt} /> {siteNameOf(sheet.site)}
316
+ </span>
317
+ )}
318
+ </PunchTimes>
319
+ </PunchMeta>
320
+ <PunchAction>
321
+ {isChecked ? (
322
+ <>
323
+ <CheckedTag>
324
+ <FontAwesomeIcon icon={faCheck} />
325
+ <FormattedMessage id="HomePage.rollCall.checked" defaultMessage="Checked" />
326
+ </CheckedTag>
327
+ <UndoBtn type="button" onClick={() => setChecked(sheet.id, false)}>
328
+ <FormattedMessage id="HomePage.rollCall.undo" defaultMessage="Undo" />
329
+ </UndoBtn>
330
+ </>
331
+ ) : (
332
+ <CheckToggle>
333
+ <input
334
+ type="checkbox"
335
+ checked={false}
336
+ onChange={() => setChecked(sheet.id, true)}
337
+ />
338
+ <FormattedMessage id="HomePage.rollCall.checked" defaultMessage="Checked" />
339
+ </CheckToggle>
340
+ )}
341
+ </PunchAction>
342
+ </PunchRow>
343
+ );
344
+ })}
345
+ </PunchList>
346
+ )}
347
+ </>
348
+ )}
349
+ </Block>
350
+ )}
351
+ </>
352
+ );
353
+ };
354
+
355
+ export default RollCall;