robobyte-front-builder 1.0.21 → 1.0.24

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.
@@ -98,7 +98,9 @@ import {
98
98
  Menu
99
99
  } from '@mui/material'
100
100
  import {AgGridReact} from "ag-grid-react";
101
- import {Edit, FilterAlt, PrintOutlined, RefreshOutlined, Save, SaveAs, Close} from "@mui/icons-material";
101
+ import {Edit, FilterAlt, PrintOutlined, RefreshOutlined, Save, SaveAs, Close, SearchOutlined} from "@mui/icons-material";
102
+ // Lazy MUI icon resolver for viewer-action button icons (icon name string → component)
103
+ import * as MuiIcons from "@mui/icons-material";
102
104
  import {getRandomInt, Helper} from "services/helper/helper";
103
105
  import PostService from 'services/PostService'
104
106
  import handleChange from 'services/helper/handleChange'
@@ -183,6 +185,14 @@ function TemplatesToolPanel() {
183
185
  handleGetTemplates, handleSaveTemplate,
184
186
  handleToggleDialogs, setIsTemplateEditing,
185
187
  builderData,
188
+ // Display settings (moved out of the in-grid toolbar) — see toolbar render
189
+ // below. These used to live next to the search field; they were moved to
190
+ // make the toolbar more compact and to follow the same disclosure pattern
191
+ // as Templates (advanced/less-used settings live in the side panel).
192
+ timerValue, setTimerValue,
193
+ isPagination, setIsPagination,
194
+ handleCSVExport, isDownloading,
195
+ isExcelExportAvailable,
186
196
  } = ctx
187
197
 
188
198
  return (
@@ -199,6 +209,57 @@ function TemplatesToolPanel() {
199
209
  </Typography>
200
210
  </Box>
201
211
 
212
+ {/* Display settings — auto-refresh timer, full-data toggle, Excel export */}
213
+ <SettingsSection title='Display Settings'>
214
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, width: '100%' }}>
215
+ <Box>
216
+ <Typography variant='caption' sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>
217
+ Auto-refresh
218
+ </Typography>
219
+ <Select
220
+ fullWidth
221
+ size='small'
222
+ value={timerValue ?? 0}
223
+ onChange={(e) => setTimerValue?.(e.target.value)}
224
+ >
225
+ <MenuItem value={0}>Off</MenuItem>
226
+ <MenuItem value={0.5}>Every 30s</MenuItem>
227
+ <MenuItem value={1}>Every 1m</MenuItem>
228
+ <MenuItem value={2}>Every 2m</MenuItem>
229
+ <MenuItem value={5}>Every 5m</MenuItem>
230
+ <MenuItem value={15}>Every 15m</MenuItem>
231
+ <MenuItem value={30}>Every 30m</MenuItem>
232
+ </Select>
233
+ </Box>
234
+
235
+ <FormControlLabel
236
+ control={
237
+ <Checkbox
238
+ size='small'
239
+ checked={!isPagination}
240
+ onChange={e => setIsPagination?.(!e.target.checked)}
241
+ color='primary'
242
+ />
243
+ }
244
+ label={<Typography variant='body2'>Load all data (no pagination)</Typography>}
245
+ />
246
+
247
+ {isExcelExportAvailable && (
248
+ <Button
249
+ size='small'
250
+ variant='outlined'
251
+ startIcon={isDownloading ? <CircularProgress size={16}/> : <FileExcelOutline fontSize='small'/>}
252
+ onClick={handleCSVExport}
253
+ disabled={isDownloading}
254
+ fullWidth
255
+ sx={{ justifyContent: 'flex-start' }}
256
+ >
257
+ Export to Excel
258
+ </Button>
259
+ )}
260
+ </Box>
261
+ </SettingsSection>
262
+
202
263
  {/* Template section */}
203
264
  <SettingsSection title='Template'>
204
265
  {builderData?.id == null ? (
@@ -283,6 +344,13 @@ const SGrid = props => {
283
344
  expireReport,
284
345
  filter: externalFilter,
285
346
  actions,
347
+ // Page-level toolbar buttons rendered after Filter/Refresh.
348
+ // Built by ReportViewerRenderer; each item already has a bound onClick.
349
+ viewerActions,
350
+ // Title / caption rendered on the left side of the toolbar, sharing the
351
+ // row with the search and action buttons. See attached design reference.
352
+ title: viewerTitle,
353
+ caption: viewerCaption,
286
354
  refresh,
287
355
  height,
288
356
  extraCols,
@@ -348,6 +416,17 @@ const SGrid = props => {
348
416
  const searchInputRef = useRef(null);
349
417
  const [searchPopoverOpen, setSearchPopoverOpen] = useState(false);
350
418
  const [searchPopoverAnchor, setSearchPopoverAnchor] = useState(null);
419
+ // Search expansion: collapsed → just an icon button; expanded → full TextField.
420
+ // Auto-expand if the user is typing or already has filters in chips, so the
421
+ // visual state always shows what's active.
422
+ const [isSearchExpanded, setIsSearchExpanded] = useState(false);
423
+ // Keyboard navigation in the search popover. Index points into
424
+ // searchFieldsRef.current. Reset whenever the popover opens or the term
425
+ // changes so the highlight always starts at the top of the list.
426
+ const [highlightedFieldIndex, setHighlightedFieldIndex] = useState(0);
427
+ // refs to ListItemButton DOM nodes so we can scrollIntoView the highlighted
428
+ // item when the user arrows past the visible part of the (capped 240px) list.
429
+ const searchOptionRefs = useRef([]);
351
430
 
352
431
  const searchTermRef = useRef('');
353
432
  const searchFieldsRef = useRef([]); // metadata only, no value
@@ -2454,397 +2533,593 @@ const SGrid = props => {
2454
2533
 
2455
2534
  return (
2456
2535
  <Grid container size={{ xs: 12 }}>
2457
- <Grid container
2458
- sx={{backgroundColor: '#fafafb', justifyContent: 'space-between'}} padding={2}
2459
- size={{ xs: 12 }}>
2460
- <Box sx={{display: 'flex'}}>
2461
- <Box sx={{ml: '5px'}}>
2462
- <Tooltip title='مؤقت' placement={'top'}>
2463
- <Select variant={'outlined'} value={timerValue} onChange={(e) => setTimerValue(e.target.value)}
2464
- defaultValue={0} size={'small'}
2465
- color='primary' sx={{width: '60px'}}>
2466
- <MenuItem key={1} value={0}>No</MenuItem>
2467
- {/*<MenuItem key={2} value={0.1667}>10s</MenuItem>*/}
2468
- <MenuItem key={2} value={0.5}>30s</MenuItem>
2469
- <MenuItem key={2} value={1}>1m</MenuItem>
2470
- <MenuItem key={2} value={2}>2m</MenuItem>
2471
- <MenuItem key={2} value={5}>5m</MenuItem>
2472
- <MenuItem key={3} value={15}>15m</MenuItem>
2473
- <MenuItem key={4} value={30}>30m</MenuItem>
2474
- </Select>
2475
- </Tooltip>
2476
- </Box>
2477
- </Box>
2478
- {((searchFieldsRef?.current?.length ?? []) > 0) &&
2479
- <Box sx={{flex: 1, px: 1, minWidth: minimized ? 140 : 300, display: 'flex', alignItems: 'center'}}>
2480
- {/*<Tooltip title={'اكتب كلمة البحث ثم اختر الحقل'}>*/}
2481
- <Box sx={{position: 'relative', width: '100%'}}>
2482
- <TextField
2483
- label={'بحث'}
2484
- fullWidth
2485
- size={'small'}
2486
- value={searchTerm}
2487
- inputRef={searchInputRef}
2488
- onFocus={(e) => {
2489
- if ((e.target.value || '').length > 0) {
2490
- setSearchPopoverAnchor(e.currentTarget);
2491
- setSearchPopoverOpen(true);
2492
- }
2493
- }}
2494
- onChange={(e) => {
2495
- const v = e.target.value;
2496
- setSearchTerm(v);
2497
- setSearchPopoverAnchor(e.currentTarget);
2498
- setSearchPopoverOpen((v || '').length > 0);
2499
- }}
2500
- onKeyDown={(e) => {
2501
- if (e.key === 'Enter') {
2502
- e.preventDefault();
2503
- const term = (searchTerm || '').trim();
2504
- if (!term) return;
2505
- const fields = searchFieldsRef?.current ?? [];
2506
- if (fields.length === 0) return;
2507
- handlePickSearchField(fields[0]);
2508
- } else if (e.key === 'Escape') {
2509
- setSearchPopoverOpen(false);
2510
- }
2536
+ <Grid
2537
+ container
2538
+ sx={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'nowrap', gap: 2 }}
2539
+ padding={2}
2540
+ size={{ xs: 12 }}
2541
+ >
2542
+ {/*
2543
+ ── Toolbar layout ───────────────────────────────────────────────────
2544
+ One row, split into:
2545
+
2546
+ LEFT : title (h6) + caption (caption) stacked vertically.
2547
+ Both are optional — the block is hidden if neither is set.
2548
+ RIGHT : collapsible search (icon → TextField on focus / when it
2549
+ has a value), Filter (outlined Button), Refresh (outlined
2550
+ Button), then any user-defined viewerActions in order.
2551
+
2552
+ Timer / Select-All / Excel-Export live in the Templates side panel
2553
+ under "Display Settings" — see TemplatesToolPanel above.
2554
+ */}
2555
+
2556
+ {/* ── LEFT: title + caption ─────────────────────────────────────── */}
2557
+ {viewerTitle || viewerCaption ? (
2558
+ <Box sx={{ display: 'flex', flexDirection: 'column', minWidth: 0, flexShrink: 1 }}>
2559
+ {viewerTitle && (
2560
+ <Typography
2561
+ variant='h6'
2562
+ sx={{
2563
+ fontWeight: 600,
2564
+ lineHeight: 1.2,
2565
+ color: 'text.primary',
2566
+ whiteSpace: 'nowrap',
2567
+ overflow: 'hidden',
2568
+ textOverflow: 'ellipsis'
2511
2569
  }}
2512
- InputProps={{
2513
- startAdornment: (
2514
- <Box sx={{display: 'flex', gap: 0.5, flexWrap: 'wrap'}}>
2515
- {(() => {
2516
- const byPath = selectedSearchObjects.reduce((acc, it) => {
2517
- const key = it.path;
2518
- if (!acc[key]) acc[key] = {meta: it, values: []};
2519
- acc[key].values.push(String(it.value));
2520
- return acc;
2521
- }, {});
2522
- return Object.keys(byPath).map(path => {
2523
- const g = byPath[path];
2524
- const labelBase = g.meta.friendlyName || g.meta.path;
2525
- const valueLabel = g.values.reduce((acc, val, idx) => {
2526
- if (idx > 0) acc.push(<strong key={`sep-${idx}`}> أو </strong>);
2527
- acc.push(val);
2528
- return acc;
2529
- }, []);
2530
- return (
2531
- <Chip
2532
- key={`group-${path}`}
2533
- size="small"
2534
- label={
2535
- <span>
2536
- <strong>{labelBase}</strong>: {valueLabel}
2537
- </span>
2538
- } onDelete={() => {
2539
- setSelectedSearchObjects(prev => prev.filter(it => it.path !== path));
2540
- debouncedRefresh();
2541
- }}
2542
- deleteIcon={
2543
- <Box sx={{
2544
- display: 'flex',
2545
- alignItems: 'center',
2546
- px: 0.5,
2547
- bgcolor: 'error.main',
2548
- color: (theme) => theme.palette.error.contrastText,
2549
- height: '100%',
2550
- alignSelf: 'stretch',
2551
- borderTopRightRadius: '4px',
2552
- borderBottomRightRadius: '4px'
2553
- }}>
2554
- <Close fontSize="small"/>
2555
- </Box>
2556
- }
2557
- sx={{
2558
- mr: 0.5,
2559
- borderRadius: '8px',
2560
- '& .MuiChip-deleteIcon': {
2561
- margin: 0
2562
- }
2563
- }}
2564
- />
2565
- );
2566
- });
2567
- })()}
2568
- </Box>
2569
- )
2570
+ >
2571
+ {viewerTitle}
2572
+ </Typography>
2573
+ )}
2574
+ {viewerCaption && (
2575
+ <Typography
2576
+ variant='caption'
2577
+ sx={{
2578
+ color: 'text.secondary',
2579
+ lineHeight: 1.2,
2580
+ whiteSpace: 'nowrap',
2581
+ overflow: 'hidden',
2582
+ textOverflow: 'ellipsis'
2570
2583
  }}
2571
- />
2584
+ >
2585
+ {viewerCaption}
2586
+ </Typography>
2587
+ )}
2588
+ </Box>
2589
+ ) : (
2590
+ // Empty spacer keeps the right-side controls right-aligned even when
2591
+ // no title is set, so layout looks consistent across reports.
2592
+ <Box />
2593
+ )}
2572
2594
 
2573
- <ClickAwayListener onClickAway={() => setSearchPopoverOpen(false)}>
2574
- <Popper
2575
- open={searchPopoverOpen}
2576
- anchorEl={searchPopoverAnchor}
2577
- placement="bottom-start"
2578
- style={{zIndex: 1300, width: searchInputRef?.current?.offsetWidth || undefined}}
2579
- >
2580
- <Paper elevation={3} sx={{mt: 0.5, maxHeight: 240, overflowY: 'auto'}}>
2581
- <List dense>
2582
- {(searchFieldsRef?.current ?? []).map((option, i) => (
2583
- <ListItemButton key={`${option.path}-${i}`} onClick={() => handlePickSearchField(option)}>
2584
- <Box sx={{display: 'flex', flexDirection: 'column'}}>
2585
- <Box sx={{fontSize: 14, fontWeight: 500}}>{option.friendlyName || option.path}</Box>
2586
- {/*<Box sx={{fontSize: 12, color: 'text.secondary'}}>{option.path}</Box>*/}
2587
- </Box>
2588
- </ListItemButton>
2589
- ))}
2590
- {((searchFieldsRef?.current ?? []).length === 0) && (
2591
- <ListItemButton disabled>
2592
- <Box sx={{px: 1, py: 0.5, color: 'text.secondary'}}>لا توجد حقول للبحث</Box>
2593
- </ListItemButton>
2594
- )}
2595
- </List>
2596
- </Paper>
2597
- </Popper>
2598
- </ClickAwayListener>
2599
- </Box>
2600
- {/*</Tooltip>*/}
2601
- </Box>}
2602
- <Box sx={{display: 'flex', justifyContent: 'center'}}>
2603
- {<Box>
2604
- <Tooltip title={'جميع البيانات'}>
2605
- <Checkbox
2606
- checked={!isPagination}
2607
- onChange={e => setIsPagination(!e.target.checked)}
2608
- color="primary"
2609
- />
2610
- </Tooltip>
2611
- </Box>}
2612
- {streamEndPoint && !builderData?.isRaw === true && minimized !== true &&
2613
- <Box>
2614
- <Tooltip title='تصدير'>
2615
- <IconButton
2616
- disabled={isDownloading}
2617
- onClick={handleCSVExport}
2618
- color='primary'
2619
- >
2620
- {isDownloading ? <CircularProgress size={24}/> : <FileExcelOutline/>}
2621
- </IconButton>
2622
- </Tooltip>
2623
- </Box>
2624
- }
2625
- {minimized !== true && !isPagination &&
2626
- <Box>
2627
- <Tooltip title="تصدير PDF">
2628
- <IconButton onClick={handlePDFExport} color="primary">
2629
- <PrintOutlined/>
2630
- </IconButton>
2631
- </Tooltip>
2632
- </Box>
2633
- }
2634
- {<Box>
2595
+ {/* ── RIGHT: search + filter + refresh + viewer actions ──────────── */}
2596
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 1, flexShrink: 0 }}>
2597
+ {(searchFieldsRef?.current?.length ?? []) > 0 &&
2598
+ (() => {
2599
+ const hasValue = (searchTerm || '').length > 0 || (selectedSearchObjects?.length ?? 0) > 0
2600
+ const expanded = isSearchExpanded || hasValue
2601
+ if (!expanded) {
2602
+ return (
2603
+ <Tooltip title='بحث' placement='top'>
2604
+ <IconButton
2605
+ size='small'
2606
+ onClick={e => {
2607
+ setIsSearchExpanded(true)
2608
+ setTimeout(() => searchInputRef.current?.focus(), 0)
2609
+ }}
2610
+ sx={{
2611
+ border: '1px solid',
2612
+ borderColor: 'divider',
2613
+ borderRadius: 1,
2614
+ color: 'text.secondary',
2615
+ '&:hover': { borderColor: 'primary.main', color: 'primary.main' }
2616
+ }}
2617
+ >
2618
+ <SearchOutlined fontSize='small' />
2619
+ </IconButton>
2620
+ </Tooltip>
2621
+ )
2622
+ }
2623
+ return (
2624
+ <Box sx={{ px: 0, minWidth: minimized ? 200 : 320, display: 'flex', alignItems: 'center' }}>
2625
+ {/*<Tooltip title={'اكتب كلمة البحث ثم اختر الحقل'}>*/}
2626
+ <Box sx={{ position: 'relative', width: '100%' }}>
2627
+ <TextField
2628
+ placeholder={'بحث...'}
2629
+ fullWidth
2630
+ size={'small'}
2631
+ value={searchTerm}
2632
+ inputRef={searchInputRef}
2633
+ // ── Stylized look matching design reference ────────────────
2634
+ // - Rounded ~12px border on a paper background.
2635
+ // - Soft divider colour at rest, primary on focus/hover.
2636
+ // - Search icon sits at the end of the input (endAdornment).
2637
+ // - No floating label — just a placeholder, so the field
2638
+ // doesn't shift vertically when it gains a value.
2639
+ sx={{
2640
+ '& .MuiOutlinedInput-root': {
2641
+ borderRadius: 3,
2642
+ bgcolor: 'background.paper',
2643
+ pr: 1.25
2644
+ },
2645
+ '& .MuiOutlinedInput-notchedOutline': {
2646
+ borderColor: 'divider'
2647
+ },
2648
+ '&:hover .MuiOutlinedInput-notchedOutline': {
2649
+ borderColor: 'text.disabled'
2650
+ },
2651
+ '& .Mui-focused .MuiOutlinedInput-notchedOutline': {
2652
+ borderWidth: 1
2653
+ }
2654
+ }}
2655
+ onFocus={e => {
2656
+ setIsSearchExpanded(true)
2657
+ if ((e.target.value || '').length > 0) {
2658
+ setSearchPopoverAnchor(e.currentTarget)
2659
+ setSearchPopoverOpen(true)
2660
+ // Reset to top of list whenever the popover opens.
2661
+ setHighlightedFieldIndex(0)
2662
+ }
2663
+ }}
2664
+ onBlur={() => {
2665
+ // Collapse back to the icon only when there's nothing to show.
2666
+ const hasValue = (searchTerm || '').length > 0 || (selectedSearchObjects?.length ?? 0) > 0
2667
+ if (!hasValue) setIsSearchExpanded(false)
2668
+ }}
2669
+ onChange={e => {
2670
+ const v = e.target.value
2671
+ setSearchTerm(v)
2672
+ setSearchPopoverAnchor(e.currentTarget)
2673
+ setSearchPopoverOpen((v || '').length > 0)
2674
+ // Term changed → list order semantically resets, so put
2675
+ // the highlight back at the top.
2676
+ setHighlightedFieldIndex(0)
2677
+ }}
2678
+ onKeyDown={e => {
2679
+ const fields = searchFieldsRef?.current ?? []
2680
+
2681
+ if (e.key === 'ArrowDown') {
2682
+ e.preventDefault()
2683
+ if (!searchPopoverOpen) {
2684
+ // Open the popover if the user starts arrowing
2685
+ // before typing — common pattern in autocompletes.
2686
+ setSearchPopoverAnchor(searchInputRef.current)
2687
+ setSearchPopoverOpen(true)
2688
+ setHighlightedFieldIndex(0)
2689
+ return
2690
+ }
2691
+ if (fields.length === 0) return
2692
+ const next = (highlightedFieldIndex + 1) % fields.length
2693
+ setHighlightedFieldIndex(next)
2694
+ // Keep the highlighted row visible inside the 240px-capped list.
2695
+ searchOptionRefs.current[next]?.scrollIntoView({ block: 'nearest' })
2696
+ return
2697
+ }
2635
2698
 
2636
- <IconButton color={'primary'} onClick={() => handleToggleDialogs('CustomFilter')}>
2637
- <FilterAlt/>
2638
- </IconButton>
2639
- </Box>
2640
- }
2641
- <Box>
2642
- <Tooltip title='اعادة تحميل'>
2643
- <IconButton onClick={() => setLocalRefresh(!localRefresh)}>
2644
- <RefreshOutlined/>
2699
+ if (e.key === 'ArrowUp') {
2700
+ e.preventDefault()
2701
+ if (!searchPopoverOpen) return
2702
+ if (fields.length === 0) return
2703
+ const prev = (highlightedFieldIndex - 1 + fields.length) % fields.length
2704
+ setHighlightedFieldIndex(prev)
2705
+ searchOptionRefs.current[prev]?.scrollIntoView({ block: 'nearest' })
2706
+ return
2707
+ }
2708
+
2709
+ if (e.key === 'Home') {
2710
+ if (!searchPopoverOpen || fields.length === 0) return
2711
+ e.preventDefault()
2712
+ setHighlightedFieldIndex(0)
2713
+ searchOptionRefs.current[0]?.scrollIntoView({ block: 'nearest' })
2714
+ return
2715
+ }
2716
+
2717
+ if (e.key === 'End') {
2718
+ if (!searchPopoverOpen || fields.length === 0) return
2719
+ e.preventDefault()
2720
+ const last = fields.length - 1
2721
+ setHighlightedFieldIndex(last)
2722
+ searchOptionRefs.current[last]?.scrollIntoView({ block: 'nearest' })
2723
+ return
2724
+ }
2725
+
2726
+ if (e.key === 'Enter') {
2727
+ e.preventDefault()
2728
+ const term = (searchTerm || '').trim()
2729
+ if (!term) return
2730
+ if (fields.length === 0) return
2731
+ // Pick the highlighted field — defaults to the first
2732
+ // (highlightedFieldIndex starts at 0), so users who
2733
+ // never touched the arrow keys still get the same
2734
+ // behaviour as before.
2735
+ const idx = Math.min(Math.max(highlightedFieldIndex, 0), fields.length - 1)
2736
+ handlePickSearchField(fields[idx])
2737
+ return
2738
+ }
2739
+
2740
+ if (e.key === 'Escape') {
2741
+ setSearchPopoverOpen(false)
2742
+ }
2743
+ }}
2744
+ InputProps={{
2745
+ startAdornment: (
2746
+ <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
2747
+ {(() => {
2748
+ const byPath = selectedSearchObjects.reduce((acc, it) => {
2749
+ const key = it.path
2750
+ if (!acc[key]) acc[key] = { meta: it, values: [] }
2751
+ acc[key].values.push(String(it.value))
2752
+ return acc
2753
+ }, {})
2754
+ return Object.keys(byPath).map(path => {
2755
+ const g = byPath[path]
2756
+ const labelBase = g.meta.friendlyName || g.meta.path
2757
+ const valueLabel = g.values.reduce((acc, val, idx) => {
2758
+ if (idx > 0) acc.push(<strong key={`sep-${idx}`}> أو </strong>)
2759
+ acc.push(val)
2760
+ return acc
2761
+ }, [])
2762
+ return (
2763
+ <Chip
2764
+ key={`group-${path}`}
2765
+ size='small'
2766
+ label={
2767
+ <span>
2768
+ <strong>{labelBase}</strong>: {valueLabel}
2769
+ </span>
2770
+ }
2771
+ onDelete={() => {
2772
+ setSelectedSearchObjects(prev => prev.filter(it => it.path !== path))
2773
+ debouncedRefresh()
2774
+ }}
2775
+ deleteIcon={
2776
+ <Box
2777
+ sx={{
2778
+ display: 'flex',
2779
+ alignItems: 'center',
2780
+ px: 0.5,
2781
+ bgcolor: 'error.main',
2782
+ color: theme => theme.palette.error.contrastText,
2783
+ height: '100%',
2784
+ alignSelf: 'stretch',
2785
+ borderTopRightRadius: '4px',
2786
+ borderBottomRightRadius: '4px'
2787
+ }}
2788
+ >
2789
+ <Close fontSize='small' />
2790
+ </Box>
2791
+ }
2792
+ sx={{
2793
+ mr: 0.5,
2794
+ borderRadius: '8px',
2795
+ '& .MuiChip-deleteIcon': {
2796
+ margin: 0
2797
+ }
2798
+ }}
2799
+ />
2800
+ )
2801
+ })
2802
+ })()}
2803
+ </Box>
2804
+ ),
2805
+ endAdornment: <SearchOutlined sx={{ color: 'text.disabled', fontSize: 20, mr: 0.25 }} />
2806
+ }}
2807
+ />
2808
+
2809
+ <ClickAwayListener onClickAway={() => setSearchPopoverOpen(false)}>
2810
+ <Popper
2811
+ open={searchPopoverOpen}
2812
+ anchorEl={searchPopoverAnchor}
2813
+ placement='bottom-start'
2814
+ style={{ zIndex: 1300, width: searchInputRef?.current?.offsetWidth || undefined }}
2815
+ >
2816
+ <Paper elevation={3} sx={{ mt: 0.5, maxHeight: 240, overflowY: 'auto' }}>
2817
+ <List dense>
2818
+ {(searchFieldsRef?.current ?? []).map((option, i) => (
2819
+ <ListItemButton
2820
+ key={`${option.path}-${i}`}
2821
+ ref={el => { searchOptionRefs.current[i] = el }}
2822
+ selected={i === highlightedFieldIndex}
2823
+ onMouseEnter={() => setHighlightedFieldIndex(i)}
2824
+ // Mouse-down (rather than click) so the input
2825
+ // doesn't lose focus before the handler runs.
2826
+ onMouseDown={(e) => {
2827
+ e.preventDefault()
2828
+ handlePickSearchField(option)
2829
+ }}
2830
+ >
2831
+ <Box sx={{ display: 'flex', flexDirection: 'column' }}>
2832
+ <Box sx={{ fontSize: 14, fontWeight: 500 }}>{option.friendlyName || option.path}</Box>
2833
+ {/*<Box sx={{fontSize: 12, color: 'text.secondary'}}>{option.path}</Box>*/}
2834
+ </Box>
2835
+ </ListItemButton>
2836
+ ))}
2837
+ {(searchFieldsRef?.current ?? []).length === 0 && (
2838
+ <ListItemButton disabled>
2839
+ <Box sx={{ px: 1, py: 0.5, color: 'text.secondary' }}>لا توجد حقول للبحث</Box>
2840
+ </ListItemButton>
2841
+ )}
2842
+ </List>
2843
+ </Paper>
2844
+ </Popper>
2845
+ </ClickAwayListener>
2846
+ </Box>
2847
+ {/*</Tooltip>*/}
2848
+ </Box>
2849
+ )
2850
+ })()}
2851
+
2852
+ {/* PDF export — only visible when the user has chosen "Load all data"
2853
+ (otherwise the grid is paginated and PDF would only print the
2854
+ current page). Kept as a compact IconButton — the primary
2855
+ actions in this toolbar are Filter / Refresh / viewerActions. */}
2856
+ {minimized !== true && !isPagination && (
2857
+ <Tooltip title='تصدير PDF'>
2858
+ <IconButton onClick={handlePDFExport} color='primary' size='small'>
2859
+ <PrintOutlined fontSize='small' />
2645
2860
  </IconButton>
2646
2861
  </Tooltip>
2647
- </Box>
2648
-
2862
+ )}
2863
+ <Button
2864
+ size='medium'
2865
+ color='primary'
2866
+ variant='outlined'
2867
+ onClick={() => setLocalRefresh(!localRefresh)}
2868
+ // sx={{ border: '1px solid' }}
2869
+ >
2870
+ <RefreshOutlined fontSize='small' />
2871
+ </Button>
2872
+ <Button
2873
+ size='small'
2874
+ variant='outlined'
2875
+ color='primary'
2876
+ startIcon={<FilterAlt fontSize='small' />}
2877
+ onClick={() => handleToggleDialogs('CustomFilter')}
2878
+ >
2879
+ Filter
2880
+ </Button>
2881
+
2882
+ {/* Viewer actions — rendered after Filter/Refresh in declared order.
2883
+ Each item is already bound by ReportViewerRenderer:
2884
+ { key, label, icon, color, variant, onClick, disabled, confirmation }
2885
+ Icon is a string name resolved against @mui/icons-material. */}
2886
+ {Array.isArray(viewerActions) &&
2887
+ viewerActions.map(action => {
2888
+ const IconCmp = action.icon ? MuiIcons[action.icon] : null
2889
+ const variant = action.variant ?? 'outlined'
2890
+ const color = action.color ?? 'primary'
2891
+ return (
2892
+ <Button
2893
+ key={action.key}
2894
+ size='small'
2895
+ variant={variant}
2896
+ color={color}
2897
+ startIcon={IconCmp ? <IconCmp fontSize='small' /> : null}
2898
+ onClick={action.onClick}
2899
+ disabled={Boolean(action.disabled)}
2900
+ >
2901
+ {action.label}
2902
+ </Button>
2903
+ )
2904
+ })}
2649
2905
  </Box>
2650
2906
  </Grid>
2651
- <TemplateStateContext.Provider value={{
2652
- templates,
2653
- selectedTemplate,
2654
- setSelectedTemplate,
2655
- handleGetTemplates,
2656
- handleSaveTemplate,
2657
- handleToggleDialogs,
2658
- setIsTemplateEditing,
2659
- builderData,
2660
- }}>
2661
- <div style={{width: "100%", height: minimized === true ? "0px" : height ?? "70vh", direction: 'ltr'}}>
2662
- <AgGridReact
2663
-
2664
- debug={false}
2665
- columnHoverHighlight={true}
2666
- theme={agTheme}
2667
- enableRtl={true}
2668
- columnDefs={colDefs.current}
2669
- rowModelType={"serverSide"}
2670
- onGridReady={onGridReady}
2671
- maxConcurrentDatasourceRequests={0}
2672
- cacheBlockSize={100}
2673
- onRowSelected={onRowSelected}
2674
- // getChildCount={getChildCount}
2675
- sideBar={sideBarConfig}
2676
- context={{
2677
- tRouting,
2678
- builderId: builderData?.id,
2679
- updateRef: updateRef,
2680
- settings: builderModel?.settings,
2681
- // Node identity available inside all column functions as params.context.nodeId / nodeName
2682
- nodeId: nodeId ?? null,
2683
- nodeName: nodeId ?? null, // alias for convenience
2684
- // Shared registry — access any report's ref: params.context.reportRefs.current['nodeId']
2685
- reportRefs: reportRefs ?? null,
2686
- // Returns the latest viewer state snapshot — used by MultiSelectEditor to commit values
2687
- getViewerContext: () => ({
2688
- data: viewerData,
2689
- setData: setData ?? (() => {}),
2690
- dataRef: viewerDataRef,
2907
+ <TemplateStateContext.Provider
2908
+ value={{
2909
+ templates,
2910
+ selectedTemplate,
2911
+ setSelectedTemplate,
2912
+ handleGetTemplates,
2913
+ handleSaveTemplate,
2914
+ handleToggleDialogs,
2915
+ setIsTemplateEditing,
2916
+ builderData,
2917
+ // ── Display settings (rendered in the side panel) ────────────────────
2918
+ timerValue,
2919
+ setTimerValue,
2920
+ isPagination,
2921
+ setIsPagination,
2922
+ handleCSVExport,
2923
+ isDownloading,
2924
+ // Hide the Excel button in the side panel for raw / minimized reports.
2925
+ isExcelExportAvailable: Boolean(streamEndPoint && !builderData?.isRaw === true && minimized !== true)
2926
+ }}
2927
+ >
2928
+ <Box
2929
+ sx={{
2930
+ width: '100%',
2931
+ height: minimized === true ? '0px' : height ?? '70vh',
2932
+ direction: 'ltr',
2933
+ // Top corners flush with the toolbar above. AG Grid's quartz theme
2934
+ // rounds .ag-root-wrapper by default; we force the two top corners
2935
+ // to 0 so the grid sits seamlessly under the toolbar regardless of
2936
+ // the toolbar's own bottom edge. Bottom corners keep their theme
2937
+ // radius so the grid still looks like a contained surface.
2938
+ '& .ag-root-wrapper': {
2939
+ borderTopLeftRadius: 0,
2940
+ borderTopRightRadius: 0
2941
+ }
2942
+ }}
2943
+ >
2944
+ <AgGridReact
2945
+ debug={false}
2946
+ columnHoverHighlight={true}
2947
+ theme={agTheme}
2948
+ enableRtl={true}
2949
+ columnDefs={colDefs.current}
2950
+ rowModelType={'serverSide'}
2951
+ onGridReady={onGridReady}
2952
+ maxConcurrentDatasourceRequests={0}
2953
+ cacheBlockSize={100}
2954
+ onRowSelected={onRowSelected}
2955
+ // getChildCount={getChildCount}
2956
+ sideBar={sideBarConfig}
2957
+ context={{
2958
+ tRouting,
2959
+ builderId: builderData?.id,
2960
+ updateRef: updateRef,
2961
+ settings: builderModel?.settings,
2962
+ // Node identity — available inside all column functions as params.context.nodeId / nodeName
2963
+ nodeId: nodeId ?? null,
2964
+ nodeName: nodeId ?? null, // alias for convenience
2965
+ // Shared registry — access any report's ref: params.context.reportRefs.current['nodeId']
2691
2966
  reportRefs: reportRefs ?? null,
2692
- }),
2693
- }} // pass routing, builder id, updateRef, node identity and reportRefs
2694
- gridOptions={{
2695
- enableRangeSelection: true,
2696
- enableCharts: true,
2697
- getContextMenuItems: (params) => {
2698
- const rowUniqueId = builderModel?.settings?.rowUniqueId
2699
- const rowData = params.node?.data
2700
- const defaultItems = params.defaultItems || []
2701
-
2702
- if (!updateRef || !rowUniqueId || !rowData) {
2703
- return defaultItems
2704
- }
2705
-
2706
- const rowIdValue = getRowIdFromSettings(rowData, rowUniqueId)
2707
- const hasUpdate = updateRef.current && Array.isArray(updateRef.current) &&
2708
- updateRef.current.some(item => item.rowId === rowIdValue)
2967
+ // Returns the latest viewer state snapshot — used by MultiSelectEditor to commit values
2968
+ getViewerContext: () => ({
2969
+ data: viewerData,
2970
+ setData: setData ?? (() => {}),
2971
+ dataRef: viewerDataRef,
2972
+ reportRefs: reportRefs ?? null
2973
+ })
2974
+ }} // pass routing, builder id, updateRef, node identity and reportRefs
2975
+ gridOptions={{
2976
+ enableRangeSelection: true,
2977
+ enableCharts: true,
2978
+ getContextMenuItems: params => {
2979
+ const rowUniqueId = builderModel?.settings?.rowUniqueId
2980
+ const rowData = params.node?.data
2981
+ const defaultItems = params.defaultItems || []
2982
+
2983
+ if (!updateRef || !rowUniqueId || !rowData) {
2984
+ return defaultItems
2985
+ }
2709
2986
 
2710
- const customItems = []
2987
+ const rowIdValue = getRowIdFromSettings(rowData, rowUniqueId)
2988
+ const hasUpdate =
2989
+ updateRef.current &&
2990
+ Array.isArray(updateRef.current) &&
2991
+ updateRef.current.some(item => item.rowId === rowIdValue)
2992
+
2993
+ const customItems = []
2994
+
2995
+ if (hasUpdate) {
2996
+ customItems.push({
2997
+ name: 'Remove Row from Update Ref',
2998
+ icon: '<span class="ag-icon ag-icon-cancel" unselectable="on" role="presentation"></span>',
2999
+ action: () => {
3000
+ removeUpdateRefByRowId(updateRef, rowData, rowUniqueId)
3001
+ params.api.refreshCells({ force: true })
3002
+ }
3003
+ })
3004
+ }
2711
3005
 
2712
- if (hasUpdate) {
2713
3006
  customItems.push({
2714
- name: 'Remove Row from Update Ref',
2715
- icon: '<span class="ag-icon ag-icon-cancel" unselectable="on" role="presentation"></span>',
3007
+ name: 'Clone Update Ref (Backup)',
3008
+ icon: '<span class="ag-icon ag-icon-copy" unselectable="on" role="presentation"></span>',
2716
3009
  action: () => {
2717
- removeUpdateRefByRowId(updateRef, rowData, rowUniqueId)
2718
- params.api.refreshCells({force: true})
2719
- }
3010
+ cloneUpdateRefToOriginal(updateRef, originalRefData)
3011
+ params.api.refreshCells({ force: true })
3012
+ },
3013
+ disabled: !updateRef.current || updateRef.current.length === 0
2720
3014
  })
2721
- }
2722
3015
 
2723
- customItems.push({
2724
- name: 'Clone Update Ref (Backup)',
2725
- icon: '<span class="ag-icon ag-icon-copy" unselectable="on" role="presentation"></span>',
2726
- action: () => {
2727
- cloneUpdateRefToOriginal(updateRef, originalRefData)
2728
- params.api.refreshCells({force: true})
2729
- },
2730
- disabled: !updateRef.current || updateRef.current.length === 0
2731
- })
3016
+ customItems.push({
3017
+ name: 'Restore Update Ref (from Backup)',
3018
+ icon: '<span class="ag-icon ag-icon-undo" unselectable="on" role="presentation"></span>',
3019
+ action: () => {
3020
+ restoreUpdateRefFromOriginal(updateRef, originalRefData)
3021
+ params.api.refreshCells({ force: true })
3022
+ },
3023
+ disabled: !originalRefData.current || originalRefData.current.length === 0
3024
+ })
2732
3025
 
2733
- customItems.push({
2734
- name: 'Restore Update Ref (from Backup)',
2735
- icon: '<span class="ag-icon ag-icon-undo" unselectable="on" role="presentation"></span>',
2736
- action: () => {
2737
- restoreUpdateRefFromOriginal(updateRef, originalRefData)
2738
- params.api.refreshCells({force: true})
2739
- },
2740
- disabled: !originalRefData.current || originalRefData.current.length === 0
2741
- })
3026
+ customItems.push({
3027
+ name: 'Normalize Update Ref (Merge Duplicates)',
3028
+ icon: '<span class="ag-icon ag-icon-columns" unselectable="on" role="presentation"></span>',
3029
+ action: () => {
3030
+ normalizeUpdateRef(updateRef)
3031
+ params.api.refreshCells({ force: true })
3032
+ },
3033
+ disabled: !updateRef.current || updateRef.current.length === 0
3034
+ })
2742
3035
 
2743
- customItems.push({
2744
- name: 'Normalize Update Ref (Merge Duplicates)',
2745
- icon: '<span class="ag-icon ag-icon-columns" unselectable="on" role="presentation"></span>',
2746
- action: () => {
2747
- normalizeUpdateRef(updateRef)
2748
- params.api.refreshCells({force: true})
2749
- },
2750
- disabled: !updateRef.current || updateRef.current.length === 0
2751
- })
3036
+ customItems.push({
3037
+ name: 'Clear All Update Ref',
3038
+ icon: '<span class="ag-icon ag-icon-cancel" unselectable="on" role="presentation"></span>',
3039
+ action: () => {
3040
+ if (updateRef.current && Array.isArray(updateRef.current)) {
3041
+ const count = updateRef.current.length
3042
+ clearAllUpdateRef(updateRef)
3043
+ params.api.refreshCells({ force: true })
3044
+ console.log(`Cleared ${count} rows from updateRef`)
3045
+ }
3046
+ },
3047
+ disabled: !updateRef.current || updateRef.current.length === 0
3048
+ })
2752
3049
 
2753
- customItems.push({
2754
- name: 'Clear All Update Ref',
2755
- icon: '<span class="ag-icon ag-icon-cancel" unselectable="on" role="presentation"></span>',
2756
- action: () => {
2757
- if (updateRef.current && Array.isArray(updateRef.current)) {
2758
- const count = updateRef.current.length
2759
- clearAllUpdateRef(updateRef)
2760
- params.api.refreshCells({force: true})
2761
- console.log(`Cleared ${count} rows from updateRef`)
2762
- }
2763
- },
2764
- disabled: !updateRef.current || updateRef.current.length === 0
2765
- })
3050
+ if (customItems.length > 0) {
3051
+ return [...customItems, 'separator', ...defaultItems]
3052
+ }
2766
3053
 
2767
- if (customItems.length > 0) {
2768
- return [
2769
- ...customItems,
2770
- 'separator',
2771
- ...defaultItems
2772
- ]
3054
+ return defaultItems
2773
3055
  }
3056
+ }}
3057
+ rowSelection='multiple'
3058
+ statusBar={{
3059
+ statusPanels: [
3060
+ // { statusPanel: 'agTotalRowCountComponent' },
3061
+ {
3062
+ statusPanel: CustomStatusBar,
3063
+ align: 'left'
3064
+ }
2774
3065
 
2775
- return defaultItems
2776
- }
2777
- }}
2778
- rowSelection="multiple"
2779
- statusBar={{
2780
- statusPanels: [
2781
- // { statusPanel: 'agTotalRowCountComponent' },
2782
- {
2783
- statusPanel: CustomStatusBar,
2784
- align: 'left',
2785
- },
2786
-
2787
- // { statusPanel: 'agFilteredRowCountComponent' },
2788
- // { statusPanel: 'agSelectedRowCountComponent' },
2789
- ],
2790
- }}
2791
- groupHeaderHeight={25}
2792
- // onChartCreated={x => console.log(x)}
2793
- headerHeight={25}
2794
- autoGroupColumnDef={autoGroupColumnDef}
2795
- getRowId={getRowId}
2796
- serverSideDatasource={datasource}
2797
- blockLoadDebounceMillis={200}
2798
- pinnedBottomRowData={pagedAgg}
2799
- suppressRowClickSelection={true}
2800
- serverSidePivotResultFieldSeparator={"_"}
2801
- pivotKeySeparator={"_"}
2802
- getRowStyle={(row) => {
2803
- if (row.node.group) {
2804
- return {fontWeight: 'bold'};
2805
- }
2806
- if (expireReport) {
2807
- if (row.node?.data?.expireDate && new Date(row.node.data.expireDate) <= new Date()) {
2808
- return {background: '#FF625F'};
3066
+ // { statusPanel: 'agFilteredRowCountComponent' },
3067
+ // { statusPanel: 'agSelectedRowCountComponent' },
3068
+ ]
3069
+ }}
3070
+ groupHeaderHeight={25}
3071
+ // onChartCreated={x => console.log(x)}
3072
+ headerHeight={25}
3073
+ autoGroupColumnDef={autoGroupColumnDef}
3074
+ getRowId={getRowId}
3075
+ serverSideDatasource={datasource}
3076
+ blockLoadDebounceMillis={200}
3077
+ pinnedBottomRowData={pagedAgg}
3078
+ suppressRowClickSelection={true}
3079
+ serverSidePivotResultFieldSeparator={'_'}
3080
+ pivotKeySeparator={'_'}
3081
+ getRowStyle={row => {
3082
+ if (row.node.group) {
3083
+ return { fontWeight: 'bold' }
2809
3084
  }
2810
- if (row.node?.data?.expireDate) {
2811
- const expireDate = new Date(row.node.data.expireDate);
2812
- const currentDate = new Date();
2813
- const diffInDays = (expireDate - currentDate) / (1000 * 60 * 60 * 24); // Convert milliseconds to days
2814
-
2815
- if (diffInDays > 10) {
2816
- return {background: '#8AFF8A'};
2817
- } else if (diffInDays > 0) {
2818
- return {background: '#fcfd74'};
3085
+ if (expireReport) {
3086
+ if (row.node?.data?.expireDate && new Date(row.node.data.expireDate) <= new Date()) {
3087
+ return { background: '#FF625F' }
3088
+ }
3089
+ if (row.node?.data?.expireDate) {
3090
+ const expireDate = new Date(row.node.data.expireDate)
3091
+ const currentDate = new Date()
3092
+ const diffInDays = (expireDate - currentDate) / (1000 * 60 * 60 * 24) // Convert milliseconds to days
3093
+
3094
+ if (diffInDays > 10) {
3095
+ return { background: '#8AFF8A' }
3096
+ } else if (diffInDays > 0) {
3097
+ return { background: '#fcfd74' }
3098
+ }
2819
3099
  }
2820
3100
  }
2821
- }
2822
- }}
2823
-
2824
- components={{
2825
- ...AG_COMPONENTS,
2826
- routingCell: RoutingCell,
2827
- imageCell: ImageCell,
2828
- customStatusBar: CustomStatusBar,
2829
- templatesToolPanel: TemplatesToolPanel,
2830
- }}
2831
-
2832
-
2833
- onCellValueChanged={params => {
2834
- // Guard: skip undefined phantom reverts produced by MultiSelectEditor's stopEditing(true) cancel path
2835
- if (params.newValue === undefined && params.colDef?.cellEditor === 'MultiSelectEditor') {
2836
- return
2837
- }
2838
- }}
2839
-
2840
- onSortChanged={params => {
2841
- const sm = params.columns;
2842
- if (sm.length == 1 && sm.some(s => s.colId === 'IZ_groupCount')) {
2843
- params.api.refreshServerSide({purge: true})
2844
- }
2845
- }}
2846
- />
2847
- </div>
3101
+ }}
3102
+ components={{
3103
+ ...AG_COMPONENTS,
3104
+ routingCell: RoutingCell,
3105
+ imageCell: ImageCell,
3106
+ customStatusBar: CustomStatusBar,
3107
+ templatesToolPanel: TemplatesToolPanel
3108
+ }}
3109
+ onCellValueChanged={params => {
3110
+ // Guard: skip undefined phantom reverts produced by MultiSelectEditor's stopEditing(true) cancel path
3111
+ if (params.newValue === undefined && params.colDef?.cellEditor === 'MultiSelectEditor') {
3112
+ return
3113
+ }
3114
+ }}
3115
+ onSortChanged={params => {
3116
+ const sm = params.columns
3117
+ if (sm.length == 1 && sm.some(s => s.colId === 'IZ_groupCount')) {
3118
+ params.api.refreshServerSide({ purge: true })
3119
+ }
3120
+ }}
3121
+ />
3122
+ </Box>
2848
3123
  </TemplateStateContext.Provider>
2849
3124
  <Dialog
2850
3125
  fullWidth
@@ -2861,7 +3136,8 @@ const SGrid = props => {
2861
3136
  pageName={'Builder' + builderData?.id}
2862
3137
  item={isTemplateEditing === true ? selectedTemplate : null}
2863
3138
  template={gridApi?.getColumnState()}
2864
- userId={authValues?.user?.id}/>
3139
+ userId={authValues?.user?.id}
3140
+ />
2865
3141
  </Dialog>
2866
3142
  <Dialog
2867
3143
  fullWidth
@@ -2873,36 +3149,32 @@ const SGrid = props => {
2873
3149
  {openDialogs.CustomFilter && (
2874
3150
  <CustomFilterDialog
2875
3151
  handleToggleDialogs={handleToggleDialogs}
2876
- Filter={(Filter?.LocalTfilter || []).filter(f => (f.isMainFilter !== true))}
3152
+ Filter={(Filter?.LocalTfilter || []).filter(f => f.isMainFilter !== true)}
2877
3153
  customFilterCode={Filter?.customFilterCode}
2878
3154
  handleFilterChange={handleFilterChange}
2879
3155
  className={builderData?.reportSource?.fullName}
2880
3156
  LocalFilter={false}
2881
-
2882
3157
  isViewer={router.pathname.includes('report/viewer')}
2883
3158
  selectTFilter={selectTFilter}
2884
3159
  selectParamsMeta={builderData?.selectionParams || []}
2885
3160
  selectParamsValues={selectParams}
2886
- onSelectParamsSave={(list) => {
3161
+ onSelectParamsSave={list => {
2887
3162
  // list is array of { index, value, type }
2888
3163
  setSelectParams(prev => {
2889
- const arr = [...(prev || [])];
2890
- (list || []).forEach(item => {
2891
- const i = item.index;
2892
- const type = item.type;
2893
- const current = arr[i] || {id: i, PropertyType: type, value: null};
2894
- arr[i] = {...current, PropertyType: current.PropertyType || type, value: item.value};
2895
- });
2896
- return arr;
2897
- });
3164
+ const arr = [...(prev || [])]
3165
+ ;(list || []).forEach(item => {
3166
+ const i = item.index
3167
+ const type = item.type
3168
+ const current = arr[i] || { id: i, PropertyType: type, value: null }
3169
+ arr[i] = { ...current, PropertyType: current.PropertyType || type, value: item.value }
3170
+ })
3171
+ return arr
3172
+ })
2898
3173
  }}
2899
3174
  />
2900
3175
  )}
2901
3176
  </Dialog>
2902
-
2903
-
2904
3177
  </Grid>
2905
-
2906
3178
  )
2907
3179
  }
2908
3180