Skip to content

Commit 50f4e76

Browse files
petabitesoma-p
andauthored
Bulk Unit Export to Excel (#115)
## Tracking Info Resolves #68 ## Changes <!-- What changes did you make? --> - implement bulk unit export - various improvements ## Testing <!-- How did you confirm your changes worked? --> - ensure exports only filtered units and associated data ## Confirmation of Change <!-- Upload a screenshot, if possible. Otherwise, please provide instructions on how to see the change. --> ![image](https://github.com/TritonSE/USHS-Housing-Portal/assets/24444266/3420f23d-7553-4780-86d9-7b93c94dd023) --------- Co-authored-by: Pranav Kumar Soma <pranavsoma11@gmail.com>
1 parent e3228fe commit 50f4e76

File tree

12 files changed

+276
-139
lines changed

12 files changed

+276
-139
lines changed

backend/bun.lockb

361 Bytes
Binary file not shown.

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"module-alias": "^2.2.3",
1212
"mongodb": "^5.7.0",
1313
"mongoose": "^7.4.0",
14-
"nodemailer": "^6.9.13"
14+
"nodemailer": "^6.9.13",
15+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
1516
},
1617
"name": "backend",
1718
"version": "1.0.0",

backend/src/controllers/units.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
approveUnit,
1313
createUnit,
1414
deleteUnit,
15+
exportUnits,
1516
getUnits,
1617
updateUnit,
1718
} from "@/services/units";
@@ -49,6 +50,15 @@ export const getUnitsHandler: RequestHandler = asyncHandler(async (req, res, _)
4950
res.status(200).json(units);
5051
});
5152

53+
export const exportUnitsHandler: RequestHandler = asyncHandler(async (req, res, _) => {
54+
const workbookBuffer = await exportUnits(req.query as FilterParams);
55+
56+
res.statusCode = 200;
57+
res.setHeader("Content-Disposition", 'attachment; filename="ushs-data-export.xlsx"');
58+
res.setHeader("Content-Type", "application/vnd.ms-excel");
59+
res.end(workbookBuffer);
60+
});
61+
5262
/**
5363
* Handle a request to get a unit.
5464
*/

backend/src/models/referral.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const referralSchema = new Schema(
88
enum: ["Referred", "Viewing", "Pending", "Approved", "Denied", "Leased", "Canceled"],
99
default: "Referred",
1010
},
11-
renterCandidate: { type: Schema.Types.ObjectId, ref: "Renter" },
11+
renterCandidate: { type: Schema.Types.ObjectId, ref: "Renter", required: true },
1212
unit: {
1313
type: Schema.Types.ObjectId,
1414
ref: "Unit",

backend/src/routes/units.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { createUnitValidators, updateUnitValidators } from "@/validators/units";
1313

1414
const router = express.Router();
1515

16+
router.get("/", requireUser, UnitController.getUnitsHandler);
17+
18+
router.get("/export", requireUser, UnitController.exportUnitsHandler);
19+
1620
router.get("/:id", requireUser, UnitController.getUnitHandler);
1721

1822
router.post(
@@ -22,8 +26,6 @@ router.post(
2226
UnitController.createUnitsHandler,
2327
);
2428

25-
router.get("/", requireUser, UnitController.getUnitsHandler);
26-
2729
router.put(
2830
"/:id",
2931
requireHousingLocator,

backend/src/services/units.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { FilterQuery, UpdateQuery } from "mongoose";
1+
import { ObjectId } from "mongodb";
2+
import { Document, FilterQuery, UpdateQuery } from "mongoose";
3+
import * as XLSX from "xlsx";
24

5+
import { ReferralModel } from "@/models/referral";
6+
import { RenterModel } from "@/models/renter";
37
import { Unit, UnitModel } from "@/models/units";
8+
import { UserModel } from "@/models/user";
49

510
type UserReadOnlyFields = "approved" | "createdAt" | "updatedAt";
611

@@ -223,3 +228,62 @@ export const getUnits = async (filters: FilterParams) => {
223228

224229
return filteredUnits;
225230
};
231+
232+
const sheetFromData = (data: Document[]) => {
233+
const sanitizedData = data.map((doc) => {
234+
// remove unneeded keys and convert all values to strings
235+
const { _id, __v, ...rest } = doc.toJSON() as Record<string, string>;
236+
const sanitizedRest = Object.keys(rest).reduce<Record<string, string>>((acc, key) => {
237+
const value = rest[key];
238+
if ((value as unknown) instanceof ObjectId) {
239+
acc[key] = value.toString();
240+
} else if (Array.isArray(value)) {
241+
acc[key] = JSON.stringify(value);
242+
} else {
243+
acc[key] = value;
244+
}
245+
return acc;
246+
}, {});
247+
248+
return {
249+
id: _id.toString(),
250+
...sanitizedRest,
251+
};
252+
});
253+
return XLSX.utils.json_to_sheet(sanitizedData);
254+
};
255+
256+
export const exportUnits = async (filters: FilterParams) => {
257+
const unitsData = await getUnits(filters);
258+
259+
const unitIds = unitsData.map((unit) => unit._id);
260+
const referralsData = await ReferralModel.find().where("unit").in(unitIds).exec();
261+
262+
const renterCandidateIds = [
263+
...new Set(referralsData.map((referral) => referral.renterCandidate)),
264+
];
265+
const renterCandidates = await RenterModel.find().where("_id").in(renterCandidateIds).exec();
266+
267+
const housingLocatorIds = [
268+
...new Set(referralsData.map((referral) => referral.assignedHousingLocator)),
269+
];
270+
const referringStaffIds = [
271+
...new Set(referralsData.map((referral) => referral.assignedReferringStaff)),
272+
];
273+
const staffIds = housingLocatorIds.concat(referringStaffIds);
274+
const staffData = await UserModel.find().where("_id").in(staffIds).exec();
275+
276+
// Generate Excel workbook
277+
const unitsSheet = sheetFromData(unitsData);
278+
const referralsSheet = sheetFromData(referralsData);
279+
const renterCandidatesSheet = sheetFromData(renterCandidates);
280+
const staffSheet = sheetFromData(staffData);
281+
282+
const workbook = XLSX.utils.book_new();
283+
XLSX.utils.book_append_sheet(workbook, unitsSheet, "Units");
284+
XLSX.utils.book_append_sheet(workbook, referralsSheet, "Referrals");
285+
XLSX.utils.book_append_sheet(workbook, renterCandidatesSheet, "Renter Candidates");
286+
XLSX.utils.book_append_sheet(workbook, staffSheet, "Staff");
287+
288+
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
289+
};

frontend/public/export-icon.svg

Lines changed: 3 additions & 0 deletions
Loading

frontend/src/api/units.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,19 @@ export async function getUnits(params: GetUnitsParams): Promise<APIResult<Unit[]
146146
}
147147
}
148148

149+
export async function exportUnits(params: GetUnitsParams): Promise<APIResult<Blob>> {
150+
try {
151+
const queryParams = new URLSearchParams(params);
152+
const url = `/units/export?${queryParams.toString()}`;
153+
const response = await get(url);
154+
155+
const data = await response.blob();
156+
return { success: true, data };
157+
} catch (error) {
158+
return handleAPIError(error);
159+
}
160+
}
161+
149162
type HousingLocatorFields =
150163
| "leasedStatus"
151164
| "whereFound"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import styled from "styled-components";
2+
3+
import { Button } from "./Button";
4+
5+
const Overlay = styled.div`
6+
width: 100vw;
7+
height: 100vh;
8+
top: 0;
9+
left: 0;
10+
right: 0;
11+
bottom: 0;
12+
position: fixed;
13+
background: rgba(0, 0, 0, 0.25);
14+
z-index: 2;
15+
`;
16+
17+
const Modal = styled.div`
18+
position: fixed;
19+
top: 50%;
20+
left: 50%;
21+
transform: translate(-50%, -50%);
22+
border-radius: 20px;
23+
background: #fff;
24+
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
25+
display: flex;
26+
flex-direction: column;
27+
align-items: center;
28+
justify-content: flex-start;
29+
gap: 30px;
30+
z-index: 2;
31+
padding: 97px 186px;
32+
`;
33+
34+
const HeadingWrapper = styled.div`
35+
font-family: "Neutraface Text";
36+
font-size: 32px;
37+
font-style: normal;
38+
font-weight: 700;
39+
text-align: center;
40+
`;
41+
42+
const MessageWrapper = styled.div`
43+
font-size: 16px;
44+
margin-top: 10px;
45+
text-align: center;
46+
`;
47+
48+
const ButtonsWrapper = styled.div`
49+
padding-top: 25px;
50+
display: flex;
51+
flex-direction: row;
52+
gap: 400px;
53+
`;
54+
55+
const Icon = styled.img`
56+
width: 78px;
57+
height: 78px;
58+
`;
59+
60+
type PopupProps = {
61+
active: boolean;
62+
onClose: () => void;
63+
};
64+
65+
export const ExportPopup = ({ active, onClose }: PopupProps) => {
66+
if (!active) return null;
67+
68+
return (
69+
<>
70+
<Overlay />
71+
<Modal>
72+
<Icon src="/dark_green_check.svg" />
73+
<div>
74+
<HeadingWrapper>Data Exporting...</HeadingWrapper>
75+
<MessageWrapper>
76+
Generating an Excel sheet with the currently filtered Units and associated Referrals and
77+
Renter Candidates. The download will start shortly...
78+
</MessageWrapper>
79+
</div>
80+
<ButtonsWrapper>
81+
<Button onClick={onClose} kind="primary">
82+
Done
83+
</Button>
84+
</ButtonsWrapper>
85+
</Modal>
86+
</>
87+
);
88+
};

frontend/src/components/UnitCard.tsx

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,19 @@ import { FiltersContext } from "@/pages/Home";
1212
const UnitCardContainer = styled.div<{ pending: boolean }>`
1313
display: flex;
1414
flex-direction: column;
15-
justify-content: flex-start;
15+
justify-content: space-between;
1616
align-content: flex-start;
17-
gap: 8px;
18-
padding-left: 20px;
19-
padding-right: 20px;
20-
padding-top: 20px;
21-
width: 318px;
17+
padding: 20px;
2218
height: 370px;
19+
width: 330px;
2320
background-color: white;
2421
2522
border-radius: 6.5px;
2623
border: 1.3px solid ${(props) => (props.pending ? "rgba(230, 159, 28, 0.50)" : "#cdcaca")};
2724
box-shadow: 1.181px 1.181px 2.362px 0px rgba(188, 186, 183, 0.4);
28-
29-
// position: absolute;
25+
&:hover {
26+
box-shadow: 0px 10px 20px 0px rgba(0, 0, 0, 0.15);
27+
}
3028
`;
3129

3230
const UnitCardText = styled.span`
@@ -56,13 +54,18 @@ const BedBathRow = styled.div`
5654
gap: 4px;
5755
`;
5856

59-
const AddressRow = styled.div`
57+
const Address = styled.div`
6058
display: flex;
6159
flex-direction: column;
6260
justify-content: flex-start;
6361
align-items: flex-start;
6462
`;
6563

64+
const BottomRow = styled.div`
65+
display: flex;
66+
justify-content: space-between;
67+
`;
68+
6669
const AvailabilityIcon = styled.img`
6770
width: 18px;
6871
height: 18px;
@@ -112,10 +115,8 @@ const BedBathText = styled(NumberText)`
112115
const DeleteIcon = styled.img`
113116
width: 22px;
114117
height: 24px;
115-
position: relative;
116-
top: -32px;
117-
left: 250px;
118118
cursor: pointer;
119+
align-self: flex-end;
119120
`;
120121

121122
const Overlay = styled.div`
@@ -311,21 +312,23 @@ export const UnitCard = ({ unit, refreshUnits }: CardProps) => {
311312
<NumberText>{unit.sqft}</NumberText>
312313
<BedBathText>sqft</BedBathText>
313314
</BedBathRow>
314-
<AddressRow>
315-
<AddressText>{unit.streetAddress}</AddressText>
316-
<AddressText>{`${unit.city}, ${unit.state} ${unit.areaCode}`}</AddressText>
317-
</AddressRow>
318-
{unit.approved && dataContext.currentUser?.isHousingLocator && (
319-
<DeleteIcon
320-
src="Trash_Icon.svg"
321-
onClick={(e) => {
322-
// Stop click from propagating to parent (opening the unit page)
323-
e.preventDefault();
324-
e.stopPropagation();
325-
setPopup(true);
326-
}}
327-
/>
328-
)}
315+
<BottomRow>
316+
<Address>
317+
<AddressText>{unit.streetAddress}</AddressText>
318+
<AddressText>{`${unit.city}, ${unit.state} ${unit.areaCode}`}</AddressText>
319+
</Address>
320+
{unit.approved && dataContext.currentUser?.isHousingLocator && (
321+
<DeleteIcon
322+
src="Trash_Icon.svg"
323+
onClick={(e) => {
324+
// Stop click from propagating to parent (opening the unit page)
325+
e.preventDefault();
326+
e.stopPropagation();
327+
setPopup(true);
328+
}}
329+
/>
330+
)}
331+
</BottomRow>
329332
</UnitCardContainer>
330333
</Link>
331334
{popup && (

0 commit comments

Comments
 (0)