@@ -14,78 +14,18 @@ import {
1414 useScrollEdges ,
1515} from '@sim/emcn'
1616import { getErrorMessage } from '@sim/utils/errors'
17- import { PEOPLE_IMPORT_MAX_ROWS , type PeopleImportResult } from '@/lib/api/contracts/people'
18- import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
17+ import type { PeopleImportResult } from '@/lib/api/contracts/people'
1918import { saveBlob } from '@/lib/uploads/client/download'
19+ import {
20+ type PeopleSheet ,
21+ readPeopleSheet ,
22+ } from '@/app/o/[organizationId]/people/read-people-sheet'
2023import { useImportPeople } from '@/hooks/queries/people'
2124
22- const MAX_FILE_BYTES = 5 * 1024 * 1024
2325const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls'
2426const TEMPLATE_CSV =
2527 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n'
2628
27- interface PeopleSheet {
28- fileName : string
29- headers : string [ ]
30- /** Each row's cells, in `headers` order. */
31- rows : string [ ] [ ]
32- }
33-
34- /**
35- * The first sheet's named columns and its rows of cells, one per sheet row (blank ones included) so the
36- * row numbers in the preview match the spreadsheet. Delimited text is read verbatim so the server
37- * interprets dates the same way for every file. A spreadsheet date cell is read from its stored
38- * value, not the text its locale displays, so `31/01/2024` and `1/31/24` both arrive as
39- * `2024-01-31`.
40- */
41- async function readPeopleSheet ( file : File ) : Promise < PeopleSheet > {
42- if ( file . size > MAX_FILE_BYTES ) throw new Error ( 'Choose a file under 5 MB' )
43- const data = new Uint8Array ( await file . arrayBuffer ( ) )
44- if ( / \. x l s x $ / i. test ( file . name ) ) await assertOoxmlPreviewWithinLimits ( data )
45- const XLSX = await import ( 'xlsx' )
46- const workbook = XLSX . read ( data , {
47- type : 'array' ,
48- raw : / \. ( c s v | t s v ) $ / i. test ( file . name ) ,
49- dense : true ,
50- // Keeps each cell's number format, which is how a date cell is told from a number.
51- cellNF : true ,
52- } )
53- const sheet = workbook . Sheets [ workbook . SheetNames [ 0 ] ?? '' ]
54- if ( ! sheet ?. [ '!ref' ] ) throw new Error ( 'The file has no rows' )
55- const range = XLSX . utils . decode_range ( sheet [ '!ref' ] )
56- const grid = sheet [ '!data' ] ?? [ ]
57- const text = ( row : number , column : number ) : string => {
58- const cell = grid [ row ] ?. [ column ]
59- if ( ! cell || cell . v === undefined || cell . v === null ) return ''
60- if ( cell . t === 'n' && XLSX . SSF . is_date ( cell . z ?? '' ) ) {
61- const date = XLSX . SSF . parse_date_code ( Number ( cell . v ) )
62- return `${ date . y } -${ String ( date . m ) . padStart ( 2 , '0' ) } -${ String ( date . d ) . padStart ( 2 , '0' ) } `
63- }
64- return String ( cell . w ?? cell . v ) . trim ( )
65- }
66- const columns : { header : string ; index : number } [ ] = [ ]
67- for ( let index = range . s . c ; index <= range . e . c ; index ++ ) {
68- const header = text ( range . s . r , index )
69- if ( ! header ) continue
70- if ( columns . some ( ( column ) => column . header === header ) ) {
71- throw new Error ( `The ${ header } column appears more than once` )
72- }
73- columns . push ( { header, index } )
74- }
75- if ( ! columns . length ) throw new Error ( 'The first row should name the columns' )
76- const rowCount = range . e . r - range . s . r
77- if ( rowCount > PEOPLE_IMPORT_MAX_ROWS ) {
78- throw new Error ( `Import at most ${ PEOPLE_IMPORT_MAX_ROWS } rows at a time` )
79- }
80- return {
81- fileName : file . name ,
82- headers : columns . map ( ( { header } ) => header ) ,
83- rows : Array . from ( { length : rowCount } , ( _ , offset ) =>
84- columns . map ( ( { index } ) => text ( range . s . r + 1 + offset , index ) )
85- ) ,
86- }
87- }
88-
8929function downloadTemplate ( ) {
9030 saveBlob ( new Blob ( [ TEMPLATE_CSV ] , { type : 'text/csv' } ) , 'people-template.csv' )
9131}
0 commit comments