@punch-in/strapi-admin 1.2.5 → 1.2.7

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.
@@ -142,20 +142,20 @@ const GlobalStyle = createGlobalStyle`
142
142
  }
143
143
 
144
144
  ::-webkit-scrollbar-track {
145
- background-color: #ffffff;
145
+ background-color: transparent;
146
146
  }
147
147
 
148
148
  ::-webkit-scrollbar-track:hover {
149
- background-color: #ffffff;
149
+ background-color: transparent;
150
150
  }
151
151
 
152
152
  ::-webkit-scrollbar-thumb {
153
- background-color: #007eff;
153
+ background-color: #c0c4cc;
154
154
  border-radius: 0.5rem;
155
155
  }
156
156
 
157
157
  ::-webkit-scrollbar-thumb:hover {
158
- background-color: #007eff;
158
+ background-color: #a6acb8;
159
159
  }
160
160
 
161
161
  ::-webkit-scrollbar-button {
@@ -165,7 +165,7 @@ const GlobalStyle = createGlobalStyle`
165
165
  /* firefox scrollbar */
166
166
  /* stylelint-disable */
167
167
  * {
168
- scrollbar-color: #ffffff #007eff;
168
+ scrollbar-color: #c0c4cc transparent;
169
169
  scrollbar-width: thin;
170
170
  }
171
171
  /* stylelint-enable */
@@ -0,0 +1,298 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, 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
+ faMapMarkerAlt,
7
+ faChevronLeft,
8
+ faChevronRight,
9
+ faSignInAlt,
10
+ faSignOutAlt,
11
+ faThumbsUp,
12
+ faCheckCircle,
13
+ faClock,
14
+ } from '@fortawesome/free-solid-svg-icons';
15
+ import moment from 'moment';
16
+ import { Block, SiteStrip, SiteTab, SiteNav, SiteNavBtn, PunchList, PunchRow, PunchMeta, PunchName, PunchTimes, PunchAction, EmptyPunch, StatusPill, ApproveBtn } from './components';
17
+
18
+ const NONE_SITE = 'none';
19
+
20
+ const startOfTodayIso = () => {
21
+ const d = new Date();
22
+ d.setHours(0, 0, 0, 0);
23
+ return d.toISOString();
24
+ };
25
+
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
+ const PunchInToday = () => {
64
+ const stripRef = useRef(null);
65
+ const [loading, setLoading] = useState(true);
66
+ const [error, setError] = useState(null);
67
+ const [sheets, setSheets] = useState([]);
68
+ const [sites, setSites] = useState([]);
69
+ const [selectedSite, setSelectedSite] = useState(null);
70
+ const [approvingId, setApprovingId] = useState(null);
71
+
72
+ const load = useCallback(async () => {
73
+ setLoading(true);
74
+ setError(null);
75
+ try {
76
+ const from = startOfTodayIso();
77
+ const [siteList, sheetList] = await Promise.all([
78
+ request('/sites?_limit=-1', { method: 'GET' }),
79
+ request('/time-sheets?from_gte=' + encodeURIComponent(from) + '&_limit=-1', { method: 'GET' }),
80
+ ]);
81
+ setSites(Array.isArray(siteList) ? siteList : []);
82
+ setSheets(Array.isArray(sheetList) ? sheetList : []);
83
+ } catch (err) {
84
+ setError('Could not load today\'s punch-ins.');
85
+ setSites([]);
86
+ setSheets([]);
87
+ } finally {
88
+ setLoading(false);
89
+ }
90
+ }, []);
91
+
92
+ useEffect(() => {
93
+ load();
94
+ }, [load]);
95
+
96
+ const grouped = useMemo(() => {
97
+ const map = {};
98
+ (sites || []).forEach((site) => {
99
+ if (!site || site.id == null) {
100
+ return;
101
+ }
102
+ const id = String(site.id);
103
+ map[id] = { id: id, name: site.name || 'Unnamed site', rows: [] };
104
+ });
105
+ (sheets || []).forEach((sheet) => {
106
+ if (!sheet || !sheet.from) {
107
+ return;
108
+ }
109
+ const id = siteIdOf(sheet.site);
110
+ if (!map[id]) {
111
+ map[id] = { id: id, name: siteNameOf(sheet.site), rows: [] };
112
+ }
113
+ map[id].rows.push(sheet);
114
+ });
115
+ const list = Object.keys(map)
116
+ .map((key) => map[key])
117
+ .sort((a, b) => {
118
+ if (a.id === NONE_SITE) {
119
+ return 1;
120
+ }
121
+ if (b.id === NONE_SITE) {
122
+ return -1;
123
+ }
124
+ return String(a.name).localeCompare(String(b.name));
125
+ });
126
+ return list;
127
+ }, [sites, sheets]);
128
+
129
+ useEffect(() => {
130
+ if (!grouped.length) {
131
+ setSelectedSite(null);
132
+ return;
133
+ }
134
+ const stillThere = grouped.some((site) => site.id === selectedSite);
135
+ if (stillThere) {
136
+ return;
137
+ }
138
+ const withPeople = grouped.find((site) => site.rows.length > 0);
139
+ setSelectedSite(withPeople ? withPeople.id : grouped[0].id);
140
+ }, [grouped, selectedSite]);
141
+
142
+ const currentIndex = grouped.findIndex((site) => site.id === selectedSite);
143
+ const current = currentIndex >= 0 ? grouped[currentIndex] : null;
144
+
145
+ const changeSite = (dir) => {
146
+ if (!grouped.length) {
147
+ return;
148
+ }
149
+ const next = (currentIndex + dir + grouped.length) % grouped.length;
150
+ setSelectedSite(grouped[next].id);
151
+ };
152
+
153
+ useEffect(() => {
154
+ if (!stripRef.current) {
155
+ return;
156
+ }
157
+ const active = stripRef.current.querySelector('[data-active="true"]');
158
+ if (active && active.scrollIntoView) {
159
+ active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
160
+ }
161
+ }, [selectedSite]);
162
+
163
+ const approve = async (id) => {
164
+ setApprovingId(id);
165
+ try {
166
+ await request('/time-sheets/' + id + '/approve', { method: 'PUT' });
167
+ setSheets((prev) =>
168
+ prev.map((sheet) => (sheet.id === id ? Object.assign({}, sheet, { approved: true }) : sheet))
169
+ );
170
+ strapi.notification.toggle({
171
+ type: 'success',
172
+ message: { id: 'HomePage.punchIn.approved', defaultMessage: 'Time approved' },
173
+ });
174
+ } catch (err) {
175
+ strapi.notification.toggle({
176
+ type: 'warning',
177
+ message: { id: 'HomePage.punchIn.approveError', defaultMessage: 'Could not approve this time-sheet' },
178
+ });
179
+ } finally {
180
+ setApprovingId(null);
181
+ }
182
+ };
183
+
184
+ const punchedCount = sheets.filter((sheet) => sheet && sheet.from).length;
185
+
186
+ return (
187
+ <Block>
188
+ <h2>
189
+ <FontAwesomeIcon icon={faClock} style={{ marginRight: 10 }} />
190
+ <FormattedMessage id="HomePage.punchIn.title" defaultMessage="Punched in today" />
191
+ </h2>
192
+ <p style={{ marginTop: 8, marginBottom: 16, color: '#5c5f66', fontSize: 14 }}>
193
+ <FormattedMessage
194
+ id="HomePage.punchIn.subtitle"
195
+ defaultMessage="{count} {count, plural, one {person} other {people}} clocked in · grouped by site"
196
+ values={{ count: punchedCount }}
197
+ />
198
+ </p>
199
+
200
+ {loading && (
201
+ <EmptyPunch>
202
+ <FormattedMessage id="HomePage.punchIn.loading" defaultMessage="Loading today's punch-ins…" />
203
+ </EmptyPunch>
204
+ )}
205
+
206
+ {!loading && error && <EmptyPunch>{error}</EmptyPunch>}
207
+
208
+ {!loading && !error && grouped.length === 0 && (
209
+ <EmptyPunch>
210
+ <FormattedMessage id="HomePage.punchIn.empty" defaultMessage="No one has punched in yet today." />
211
+ </EmptyPunch>
212
+ )}
213
+
214
+ {!loading && !error && grouped.length > 0 && (
215
+ <>
216
+ <SiteNav>
217
+ <SiteNavBtn type="button" onClick={() => changeSite(-1)} aria-label="Previous site">
218
+ <FontAwesomeIcon icon={faChevronLeft} />
219
+ </SiteNavBtn>
220
+ <SiteStrip ref={stripRef}>
221
+ {grouped.map((site) => (
222
+ <SiteTab
223
+ key={site.id}
224
+ type="button"
225
+ $active={site.id === selectedSite}
226
+ data-active={site.id === selectedSite ? 'true' : undefined}
227
+ onClick={() => setSelectedSite(site.id)}
228
+ >
229
+ <FontAwesomeIcon icon={faMapMarkerAlt} />
230
+ <span>{site.name}</span>
231
+ <em>{site.rows.length}</em>
232
+ </SiteTab>
233
+ ))}
234
+ </SiteStrip>
235
+ <SiteNavBtn type="button" onClick={() => changeSite(1)} aria-label="Next site">
236
+ <FontAwesomeIcon icon={faChevronRight} />
237
+ </SiteNavBtn>
238
+ </SiteNav>
239
+
240
+ {!current || current.rows.length === 0 ? (
241
+ <EmptyPunch>
242
+ <FormattedMessage
243
+ id="HomePage.punchIn.noneAtSite"
244
+ defaultMessage="No one has punched in at this site today."
245
+ />
246
+ </EmptyPunch>
247
+ ) : (
248
+ <PunchList>
249
+ {current.rows
250
+ .slice()
251
+ .sort((a, b) => String(displayName(a.users)).localeCompare(String(displayName(b.users))))
252
+ .map((sheet) => {
253
+ const fromLabel = clockLabel(sheet.from);
254
+ const untilLabel = clockLabel(sheet.until);
255
+ const canApprove = Boolean(sheet.from && sheet.until && !sheet.approved);
256
+ return (
257
+ <PunchRow key={sheet.id}>
258
+ <PunchMeta>
259
+ <PunchName>{displayName(sheet.users)}</PunchName>
260
+ <PunchTimes>
261
+ <span>
262
+ <FontAwesomeIcon icon={faSignInAlt} /> {fromLabel || '—'}
263
+ </span>
264
+ <span>
265
+ <FontAwesomeIcon icon={faSignOutAlt} /> {untilLabel || 'On site'}
266
+ </span>
267
+ </PunchTimes>
268
+ </PunchMeta>
269
+ <PunchAction>
270
+ {sheet.approved ? (
271
+ <StatusPill $ok>
272
+ <FontAwesomeIcon icon={faCheckCircle} /> Approved
273
+ </StatusPill>
274
+ ) : canApprove ? (
275
+ <ApproveBtn
276
+ type="button"
277
+ disabled={approvingId === sheet.id}
278
+ onClick={() => approve(sheet.id)}
279
+ >
280
+ <FontAwesomeIcon icon={faThumbsUp} />
281
+ {approvingId === sheet.id ? 'Saving…' : 'Approve'}
282
+ </ApproveBtn>
283
+ ) : (
284
+ <StatusPill>Awaiting clock-out</StatusPill>
285
+ )}
286
+ </PunchAction>
287
+ </PunchRow>
288
+ );
289
+ })}
290
+ </PunchList>
291
+ )}
292
+ </>
293
+ )}
294
+ </Block>
295
+ );
296
+ };
297
+
298
+ export default PunchInToday;
@@ -257,4 +257,162 @@ const SocialLinkWrapper = styled.div`
257
257
  }
258
258
  `;
259
259
 
260
- export { ALink, Block, Container, LinkWrapper, P, Separator, SocialLinkWrapper, Wave };
260
+ const SiteNav = styled.div`
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 8px;
264
+ margin-bottom: 16px;
265
+ `;
266
+
267
+ const SiteStrip = styled.div`
268
+ display: flex;
269
+ gap: 8px;
270
+ overflow-x: auto;
271
+ flex: 1;
272
+ min-width: 0;
273
+ padding: 4px 2px 8px;
274
+ scroll-behavior: smooth;
275
+
276
+ &::-webkit-scrollbar {
277
+ height: 6px;
278
+ }
279
+ &::-webkit-scrollbar-thumb {
280
+ background: #c0c4cc;
281
+ border-radius: 3px;
282
+ }
283
+ `;
284
+
285
+ const SiteTab = styled.button`
286
+ display: inline-flex;
287
+ align-items: center;
288
+ gap: 8px;
289
+ flex: 0 0 auto;
290
+ height: 34px;
291
+ padding: 0 12px;
292
+ border: 1px solid ${props => (props.$active ? '#007eff' : '#e3e9f3')};
293
+ border-radius: 17px;
294
+ background: ${props => (props.$active ? '#007eff' : '#ffffff')};
295
+ color: ${props => (props.$active ? '#ffffff' : '#333740')};
296
+ font-size: 13px;
297
+ font-weight: 600;
298
+ cursor: pointer;
299
+ white-space: nowrap;
300
+
301
+ em {
302
+ font-style: normal;
303
+ min-width: 18px;
304
+ height: 18px;
305
+ padding: 0 5px;
306
+ border-radius: 9px;
307
+ background: ${props => (props.$active ? 'rgba(255,255,255,0.2)' : '#f0f3f8')};
308
+ font-size: 11px;
309
+ line-height: 18px;
310
+ text-align: center;
311
+ }
312
+ `;
313
+
314
+ const SiteNavBtn = styled.button`
315
+ width: 32px;
316
+ height: 32px;
317
+ flex: 0 0 32px;
318
+ border: 1px solid #e3e9f3;
319
+ border-radius: 4px;
320
+ background: #ffffff;
321
+ color: #007eff;
322
+ cursor: pointer;
323
+
324
+ &:hover {
325
+ background: #f7f8f8;
326
+ }
327
+ `;
328
+
329
+ const PunchList = styled.div`
330
+ max-height: 360px;
331
+ overflow-y: auto;
332
+ padding-right: 10px;
333
+ `;
334
+
335
+ const PunchRow = styled.div`
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ gap: 12px;
340
+ padding: 10px 0;
341
+ border-bottom: 1px solid #f0f3f8;
342
+
343
+ &:last-child {
344
+ border-bottom: none;
345
+ }
346
+ `;
347
+
348
+ const PunchMeta = styled.div`
349
+ min-width: 0;
350
+ `;
351
+
352
+ const PunchName = styled.div`
353
+ font-size: 14px;
354
+ font-weight: 600;
355
+ color: #333740;
356
+ `;
357
+
358
+ const PunchTimes = styled.div`
359
+ display: flex;
360
+ gap: 14px;
361
+ margin-top: 4px;
362
+ font-size: 12px;
363
+ color: #5c5f66;
364
+
365
+ svg {
366
+ margin-right: 4px;
367
+ color: #007eff;
368
+ }
369
+ `;
370
+
371
+ const PunchAction = styled.div`
372
+ flex: 0 0 auto;
373
+ `;
374
+
375
+ const EmptyPunch = styled.p`
376
+ margin: 8px 0 0;
377
+ color: #5c5f66;
378
+ font-size: 14px;
379
+ `;
380
+
381
+ const StatusPill = styled.span`
382
+ display: inline-flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ height: 28px;
386
+ padding: 0 10px;
387
+ border-radius: 14px;
388
+ font-size: 12px;
389
+ font-weight: 600;
390
+ color: ${props => (props.$ok ? '#27b97c' : '#8e8ea9')};
391
+ background: ${props => (props.$ok ? '#eafbe7' : '#f0f3f8')};
392
+ `;
393
+
394
+ const ApproveBtn = styled.button`
395
+ display: inline-flex;
396
+ align-items: center;
397
+ gap: 6px;
398
+ height: 32px;
399
+ padding: 0 12px;
400
+ border: none;
401
+ border-radius: 4px;
402
+ background: #007eff;
403
+ color: #ffffff;
404
+ font-size: 13px;
405
+ font-weight: 600;
406
+ cursor: pointer;
407
+
408
+ &:disabled {
409
+ opacity: 0.6;
410
+ cursor: default;
411
+ }
412
+
413
+ &:hover:not(:disabled) {
414
+ background: #005fea;
415
+ }
416
+ `;
417
+
418
+ export { ALink, Block, Container, LinkWrapper, P, Separator, SocialLinkWrapper, Wave, SiteStrip, SiteTab, SiteNav, SiteNavBtn, PunchList, PunchRow, PunchMeta, PunchName, PunchTimes, PunchAction, EmptyPunch, StatusPill, ApproveBtn };
@@ -14,6 +14,7 @@ import { useModels } from '../../hooks';
14
14
  import useFetch from './hooks';
15
15
  import { ALink, Block, Container, LinkWrapper, P, Wave, Separator } from './components';
16
16
  import SocialLink from './SocialLink';
17
+ import PunchInToday from './PunchInToday';
17
18
 
18
19
  const FIRST_BLOCK_LINKS = [
19
20
  {
@@ -86,6 +87,11 @@ const HomePage = ({ history: { push } }) => {
86
87
  {title => <PageTitle title={title} />}
87
88
  </FormattedMessage>
88
89
  <Container className="container-fluid">
90
+ <div className="row">
91
+ <div className="col-12">
92
+ <PunchInToday />
93
+ </div>
94
+ </div>
89
95
  <div className="row">
90
96
  <div className="col-lg-8 col-md-12">
91
97
  <Block>
@@ -54,6 +54,13 @@
54
54
  "HomePage.community": "Join Punch-in Community",
55
55
  "HomePage.greetings": "Hi {name}!",
56
56
  "HomePage.helmet.title": "Homepage",
57
+ "HomePage.punchIn.title": "Attendance recorded",
58
+ "HomePage.punchIn.subtitle": "{count} {count, plural, one {person} other {people}} clocked in · grouped by site",
59
+ "HomePage.punchIn.loading": "Loading today's punch-ins…",
60
+ "HomePage.punchIn.empty": "No one has punched in yet today.",
61
+ "HomePage.punchIn.noneAtSite": "No one has punched in at this site today.",
62
+ "HomePage.punchIn.approved": "Time approved",
63
+ "HomePage.punchIn.approveError": "Could not approve this time-sheet",
57
64
  "HomePage.roadmap": "See our roadmap",
58
65
  "HomePage.welcome.congrats": "Congrats!",
59
66
  "HomePage.welcome.congrats.content": "You are logged in as the first administrator. To discover the powerful features provided by Punch-in,",
package/package.json CHANGED
@@ -139,5 +139,5 @@
139
139
  "develop:ce": "STRAPI_DISABLE_EE=true webpack-dev-server --config webpack.config.dev.js",
140
140
  "test": "echo \"no tests yet\""
141
141
  },
142
- "version": "1.2.5"
142
+ "version": "1.2.7"
143
143
  }