because I got bored of customising my CV for every job
1import { Button, Card, TextInput } from "@cv/ui";
2import { keepPreviousData, useQueryClient } from "@tanstack/react-query";
3import { useState } from "react";
4import {
5 useAdminCreateLookupEntityMutation,
6 useAdminDeleteLookupEntityMutation,
7 useAdminLookupEntitiesQuery,
8 useAdminUpdateLookupEntityMutation,
9} from "@/generated/graphql";
10
11interface AdminLookupTableProps {
12 entityType: string;
13 title: string;
14}
15
16type LookupEntity = {
17 id: string;
18 name: string;
19 description: string | null;
20 createdAt: string;
21 updatedAt: string;
22};
23
24const formatDate = (iso: string) =>
25 new Date(iso).toLocaleDateString(undefined, {
26 year: "numeric",
27 month: "short",
28 day: "numeric",
29 });
30
31export const AdminLookupTable = ({
32 entityType,
33 title,
34}: AdminLookupTableProps) => {
35 const queryClient = useQueryClient();
36 const [searchTerm, setSearchTerm] = useState("");
37 const [newName, setNewName] = useState("");
38 const [newDescription, setNewDescription] = useState("");
39 const [editingId, setEditingId] = useState<string | null>(null);
40 const [editName, setEditName] = useState("");
41 const [editDescription, setEditDescription] = useState("");
42 const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
43
44 const queryKey = ["AdminLookupEntities", { entityType, searchTerm }];
45
46 const { data, isLoading } = useAdminLookupEntitiesQuery(
47 {
48 entityType: entityType as never,
49 searchTerm: searchTerm || undefined,
50 },
51 { placeholderData: keepPreviousData },
52 );
53
54 const invalidate = () =>
55 queryClient.invalidateQueries({ queryKey: ["AdminLookupEntities"] });
56
57 const createMutation = useAdminCreateLookupEntityMutation({
58 onSuccess: () => {
59 invalidate();
60 setNewName("");
61 setNewDescription("");
62 },
63 });
64
65 const updateMutation = useAdminUpdateLookupEntityMutation({
66 onSuccess: () => {
67 invalidate();
68 setEditingId(null);
69 },
70 });
71
72 const deleteMutation = useAdminDeleteLookupEntityMutation({
73 onSuccess: () => {
74 invalidate();
75 setDeleteConfirmId(null);
76 },
77 });
78
79 const handleCreate = (e: React.FormEvent) => {
80 e.preventDefault();
81 if (!newName.trim()) return;
82 createMutation.mutate({
83 entityType: entityType as never,
84 name: newName.trim(),
85 description: newDescription.trim() || undefined,
86 });
87 };
88
89 const handleUpdate = (id: string) => {
90 updateMutation.mutate({
91 entityType: entityType as never,
92 id,
93 name: editName.trim() || undefined,
94 description: editDescription,
95 });
96 };
97
98 const handleDelete = (id: string) => {
99 deleteMutation.mutate({ entityType: entityType as never, id });
100 };
101
102 const startEditing = (entity: LookupEntity) => {
103 setEditingId(entity.id);
104 setEditName(entity.name);
105 setEditDescription(entity.description ?? "");
106 };
107
108 const entities = (data?.adminLookupEntities ?? []) as LookupEntity[];
109
110 return (
111 <Card>
112 <div className="mb-4 flex items-center justify-between">
113 <h3 className="text-lg font-medium text-ctp-text">{title}</h3>
114 <span className="text-xs text-ctp-subtext0">
115 {entities.length} {entities.length === 1 ? "item" : "items"}
116 </span>
117 </div>
118
119 <div className="mb-4">
120 <TextInput
121 label=""
122 placeholder="Search..."
123 value={searchTerm}
124 onChange={setSearchTerm}
125 />
126 </div>
127
128 <form onSubmit={handleCreate} className="mb-4 flex gap-2">
129 <TextInput
130 label=""
131 placeholder="Name"
132 value={newName}
133 onChange={setNewName}
134 />
135 <TextInput
136 label=""
137 placeholder="Description (optional)"
138 value={newDescription}
139 onChange={setNewDescription}
140 />
141 <Button type="submit" disabled={!newName.trim()}>
142 Add
143 </Button>
144 </form>
145
146 {isLoading ? (
147 <p className="text-sm text-ctp-subtext0">Loading...</p>
148 ) : entities.length === 0 ? (
149 <p className="text-sm text-ctp-subtext0">No entities found</p>
150 ) : (
151 <div className="overflow-x-auto">
152 <table className="w-full text-sm">
153 <thead>
154 <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0">
155 <th className="pb-2 pr-4 font-medium">Name</th>
156 <th className="pb-2 pr-4 font-medium">Description</th>
157 <th className="pb-2 pr-4 font-medium">Created</th>
158 <th className="pb-2 pr-4 font-medium text-right">Actions</th>
159 </tr>
160 </thead>
161 <tbody>
162 {entities.map((entity) => (
163 <tr
164 key={entity.id}
165 className="border-b border-ctp-surface1 last:border-b-0"
166 >
167 {editingId === entity.id ? (
168 <>
169 <td className="py-2 pr-4">
170 <TextInput
171 label=""
172 value={editName}
173 onChange={setEditName}
174 />
175 </td>
176 <td className="py-2 pr-4">
177 <TextInput
178 label=""
179 value={editDescription}
180 onChange={setEditDescription}
181 />
182 </td>
183 <td className="py-2 pr-4 text-ctp-subtext0">
184 {formatDate(entity.createdAt)}
185 </td>
186 <td className="py-2 pr-4 text-right">
187 <div className="flex justify-end gap-1">
188 <Button
189 variant="ghost"
190 size="sm"
191 onClick={() => handleUpdate(entity.id)}
192 >
193 Save
194 </Button>
195 <Button
196 variant="ghost"
197 size="sm"
198 onClick={() => setEditingId(null)}
199 >
200 Cancel
201 </Button>
202 </div>
203 </td>
204 </>
205 ) : (
206 <>
207 <td className="py-2 pr-4 text-ctp-text">
208 {entity.name}
209 </td>
210 <td className="py-2 pr-4 text-ctp-subtext0">
211 {entity.description ?? "-"}
212 </td>
213 <td className="py-2 pr-4 text-ctp-subtext0">
214 {formatDate(entity.createdAt)}
215 </td>
216 <td className="py-2 pr-4 text-right">
217 {deleteConfirmId === entity.id ? (
218 <div className="flex justify-end gap-1">
219 <Button
220 variant="ghost"
221 size="sm"
222 onClick={() => handleDelete(entity.id)}
223 >
224 Confirm
225 </Button>
226 <Button
227 variant="ghost"
228 size="sm"
229 onClick={() => setDeleteConfirmId(null)}
230 >
231 Cancel
232 </Button>
233 </div>
234 ) : (
235 <div className="flex justify-end gap-1">
236 <Button
237 variant="ghost"
238 size="sm"
239 onClick={() => startEditing(entity)}
240 >
241 Edit
242 </Button>
243 <Button
244 variant="ghost"
245 size="sm"
246 onClick={() => setDeleteConfirmId(entity.id)}
247 >
248 Delete
249 </Button>
250 </div>
251 )}
252 </td>
253 </>
254 )}
255 </tr>
256 ))}
257 </tbody>
258 </table>
259 </div>
260 )}
261 </Card>
262 );
263};