prime-ui-kit 0.11.0 → 0.12.0
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.
- package/README.md +2 -1
- package/bin/prime-ui-kit.js +0 -0
- package/dist/chunks/{chunk-GKX3QAUC.js → chunk-OTOIOHVE.js} +1 -1
- package/dist/chunks/{chunk-WDTSHOTD.js → chunk-VOZ7J72X.js} +7 -2
- package/dist/chunks/{chunk-CUH6DJ57.js → chunk-YJO3NJIJ.js} +716 -548
- package/dist/color-picker.js +2 -2
- package/dist/components/index.d.ts +2 -0
- package/dist/components/list/List.d.ts +105 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -2
- package/dist/internal/VisuallyHidden.d.ts +6 -1
- package/dist/skill/api-contract.md +1 -1
- package/dist/skill/cheatsheet.md +1 -1
- package/dist/skill/choosing.md +4 -2
- package/dist/skill/components.md +2 -1
- package/dist/skill/composition.md +8 -0
- package/dist/skill/layouts.md +2 -1
- package/dist/skill/patterns/app-shell.module.css +18 -0
- package/dist/skill/patterns/app-shell.tsx +301 -0
- package/dist/skill/patterns/chat.module.css +0 -16
- package/dist/skill/patterns/chat.tsx +16 -19
- package/dist/skill/patterns/kanban-board.module.css +28 -0
- package/dist/skill/patterns/kanban-board.tsx +618 -0
- package/dist/skill/patterns/media-library.module.css +66 -0
- package/dist/skill/patterns/media-library.tsx +638 -0
- package/dist/skill/patterns/schedule.module.css +22 -0
- package/dist/skill/patterns/schedule.tsx +710 -0
- package/dist/skill/reference/components/file-upload/COMPONENT.md +3 -1
- package/dist/skill/reference/components/list/COMPONENT.md +180 -0
- package/dist/skill/reference/components/list/examples/as-link.tsx +37 -0
- package/dist/skill/reference/components/list/examples/examples.module.css +18 -0
- package/dist/skill/reference/components/list/examples/overflow.tsx +46 -0
- package/dist/skill/reference/components/list/examples/overview.tsx +63 -0
- package/dist/skill/reference/components/list/examples/sizes.tsx +35 -0
- package/dist/skill/reference/components/list/examples/states.tsx +27 -0
- package/dist/skill/reference/components/list/examples/with-icon.tsx +60 -0
- package/dist/skill/reference/components/page-content/COMPONENT.md +2 -2
- package/dist/skill/reference/components/page-content/examples/examples.module.css +5 -12
- package/dist/skill/reference/components/page-content/examples/list-detail.tsx +13 -18
- package/dist/skill/reference/components/visually-hidden/COMPONENT.md +6 -3
- package/dist/skill/reference/components/visually-hidden/examples/overview.tsx +2 -2
- package/package.json +3 -3
- package/src/components/file-upload/COMPONENT.md +3 -1
- package/src/components/list/COMPONENT.md +180 -0
- package/src/components/list/examples/as-link.tsx +37 -0
- package/src/components/list/examples/examples.module.css +18 -0
- package/src/components/list/examples/overflow.tsx +46 -0
- package/src/components/list/examples/overview.tsx +63 -0
- package/src/components/list/examples/sizes.tsx +35 -0
- package/src/components/list/examples/states.tsx +27 -0
- package/src/components/list/examples/with-icon.tsx +60 -0
- package/src/components/page-content/COMPONENT.md +2 -2
- package/src/components/page-content/examples/examples.module.css +5 -12
- package/src/components/page-content/examples/list-detail.tsx +13 -18
- package/src/components/visually-hidden/COMPONENT.md +6 -3
- package/src/components/visually-hidden/examples/overview.tsx +2 -2
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/** A sales pipeline board: header with one primary action, search and filter with a board / list switch, a Kanban of deals by stage with totals, moves saved on the server that can be refused, movable stages and the deal in a Drawer. */
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Crossfade,
|
|
7
|
+
DataTable,
|
|
8
|
+
type DataTableColumn,
|
|
9
|
+
DescriptionList,
|
|
10
|
+
Drawer,
|
|
11
|
+
Dropdown,
|
|
12
|
+
EmptyPage,
|
|
13
|
+
Icon,
|
|
14
|
+
Kanban,
|
|
15
|
+
type KanbanColumn,
|
|
16
|
+
type KanbanValue,
|
|
17
|
+
matchesSmartFilter,
|
|
18
|
+
PageContent,
|
|
19
|
+
PageToolbar,
|
|
20
|
+
type PaletteColor,
|
|
21
|
+
SegmentedControl,
|
|
22
|
+
SmartFilter,
|
|
23
|
+
type SmartFilterField,
|
|
24
|
+
type SmartFilterValue,
|
|
25
|
+
Typography,
|
|
26
|
+
useNotifications,
|
|
27
|
+
} from "prime-ui-kit";
|
|
28
|
+
import * as React from "react";
|
|
29
|
+
|
|
30
|
+
import styles from "./kanban-board.module.css";
|
|
31
|
+
|
|
32
|
+
type Owner = { name: string; initials: string; color: PaletteColor };
|
|
33
|
+
|
|
34
|
+
type Deal = {
|
|
35
|
+
id: string;
|
|
36
|
+
company: string;
|
|
37
|
+
amount: number;
|
|
38
|
+
owner: Owner;
|
|
39
|
+
due: string;
|
|
40
|
+
contact: string;
|
|
41
|
+
source: string;
|
|
42
|
+
probability: number;
|
|
43
|
+
contract: boolean;
|
|
44
|
+
comments: number;
|
|
45
|
+
tags: { text: string; color: PaletteColor }[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type DealRow = Deal & { stage: string };
|
|
49
|
+
|
|
50
|
+
/** The stages of the pipeline; the first and the last are system stages and stay in place. */
|
|
51
|
+
const STAGES: KanbanColumn[] = [
|
|
52
|
+
{
|
|
53
|
+
id: "new",
|
|
54
|
+
title: "Новая",
|
|
55
|
+
description: "Связаться в течение дня",
|
|
56
|
+
color: "blue",
|
|
57
|
+
locked: true,
|
|
58
|
+
},
|
|
59
|
+
{ id: "qualified", title: "Квалификация", description: "Бюджет и сроки", color: "purple" },
|
|
60
|
+
{ id: "proposal", title: "Предложение", description: "КП отправлено", color: "orange" },
|
|
61
|
+
{ id: "negotiation", title: "Переговоры", description: "Условия договора", color: "yellow" },
|
|
62
|
+
{ id: "won", title: "Выиграна", description: "Договор подписан", color: "green", locked: true },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const ANNA: Owner = { name: "Анна Климова", initials: "АК", color: "purple" };
|
|
66
|
+
const ILYA: Owner = { name: "Илья Петров", initials: "ИП", color: "teal" };
|
|
67
|
+
const MARAT: Owner = { name: "Марат Хасанов", initials: "МХ", color: "orange" };
|
|
68
|
+
|
|
69
|
+
const DEALS: Deal[] = [
|
|
70
|
+
{
|
|
71
|
+
id: "DL-341",
|
|
72
|
+
company: "ООО «Северный ветер»",
|
|
73
|
+
amount: 480_000,
|
|
74
|
+
owner: ANNA,
|
|
75
|
+
due: "2026-10-24",
|
|
76
|
+
contact: "Ольга Белова, закупки",
|
|
77
|
+
source: "Сайт",
|
|
78
|
+
probability: 20,
|
|
79
|
+
contract: false,
|
|
80
|
+
comments: 2,
|
|
81
|
+
tags: [{ text: "Входящая", color: "sky" }],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "DL-338",
|
|
85
|
+
company: "АО «Техностиль»",
|
|
86
|
+
amount: 1_240_000,
|
|
87
|
+
owner: ILYA,
|
|
88
|
+
due: "2026-10-30",
|
|
89
|
+
contact: "Дмитрий Орлов, ИТ-директор",
|
|
90
|
+
source: "Выставка",
|
|
91
|
+
probability: 25,
|
|
92
|
+
contract: false,
|
|
93
|
+
comments: 0,
|
|
94
|
+
tags: [{ text: "Тендер", color: "blue" }],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "DL-332",
|
|
98
|
+
company: "ООО «Логистик Плюс»",
|
|
99
|
+
amount: 312_000,
|
|
100
|
+
owner: MARAT,
|
|
101
|
+
due: "2026-10-19",
|
|
102
|
+
contact: "Сергей Ким, операционный директор",
|
|
103
|
+
source: "Рекомендация",
|
|
104
|
+
probability: 40,
|
|
105
|
+
contract: false,
|
|
106
|
+
comments: 4,
|
|
107
|
+
tags: [{ text: "Повторная", color: "green" }],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "DL-327",
|
|
111
|
+
company: "ООО «Гранит»",
|
|
112
|
+
amount: 94_750,
|
|
113
|
+
owner: ANNA,
|
|
114
|
+
due: "2026-10-12",
|
|
115
|
+
contact: "Наталья Фомина, финансы",
|
|
116
|
+
source: "Сайт",
|
|
117
|
+
probability: 40,
|
|
118
|
+
contract: false,
|
|
119
|
+
comments: 1,
|
|
120
|
+
tags: [{ text: "Просрочена", color: "red" }],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "DL-321",
|
|
124
|
+
company: "ЗАО «Альфа Медиа»",
|
|
125
|
+
amount: 760_000,
|
|
126
|
+
owner: ILYA,
|
|
127
|
+
due: "2026-10-28",
|
|
128
|
+
contact: "Екатерина Лаптева, маркетинг",
|
|
129
|
+
source: "Холодный звонок",
|
|
130
|
+
probability: 60,
|
|
131
|
+
contract: false,
|
|
132
|
+
comments: 6,
|
|
133
|
+
tags: [{ text: "Горячая", color: "orange" }],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "DL-316",
|
|
137
|
+
company: "ООО «Мостострой»",
|
|
138
|
+
amount: 2_480_000,
|
|
139
|
+
owner: MARAT,
|
|
140
|
+
due: "2026-11-06",
|
|
141
|
+
contact: "Андрей Зуев, генеральный директор",
|
|
142
|
+
source: "Рекомендация",
|
|
143
|
+
probability: 75,
|
|
144
|
+
contract: true,
|
|
145
|
+
comments: 9,
|
|
146
|
+
tags: [
|
|
147
|
+
{ text: "Тендер", color: "blue" },
|
|
148
|
+
{ text: "Горячая", color: "orange" },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "DL-309",
|
|
153
|
+
company: "АО «Волга-Энерго»",
|
|
154
|
+
amount: 1_095_000,
|
|
155
|
+
owner: ANNA,
|
|
156
|
+
due: "2026-10-21",
|
|
157
|
+
contact: "Игорь Щукин, закупки",
|
|
158
|
+
source: "Выставка",
|
|
159
|
+
probability: 80,
|
|
160
|
+
contract: false,
|
|
161
|
+
comments: 3,
|
|
162
|
+
tags: [{ text: "Ждём подпись", color: "yellow" }],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "DL-298",
|
|
166
|
+
company: "ООО «Зелёный квартал»",
|
|
167
|
+
amount: 205_300,
|
|
168
|
+
owner: ILYA,
|
|
169
|
+
due: "2026-10-08",
|
|
170
|
+
contact: "Мария Глебова, офис-менеджер",
|
|
171
|
+
source: "Сайт",
|
|
172
|
+
probability: 100,
|
|
173
|
+
contract: true,
|
|
174
|
+
comments: 2,
|
|
175
|
+
tags: [{ text: "Повторная", color: "green" }],
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const PLACEMENT: KanbanValue = {
|
|
180
|
+
new: ["DL-341", "DL-338"],
|
|
181
|
+
qualified: ["DL-332", "DL-327"],
|
|
182
|
+
proposal: ["DL-321"],
|
|
183
|
+
negotiation: ["DL-316", "DL-309"],
|
|
184
|
+
won: ["DL-298"],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const BY_ID = new Map(DEALS.map((deal) => [deal.id, deal]));
|
|
188
|
+
const STAGE_BY_ID = new Map(STAGES.map((stage) => [stage.id, stage]));
|
|
189
|
+
const OWNERS = [ANNA, ILYA, MARAT];
|
|
190
|
+
|
|
191
|
+
const RUBLES = new Intl.NumberFormat("ru-RU", {
|
|
192
|
+
style: "currency",
|
|
193
|
+
currency: "RUB",
|
|
194
|
+
maximumFractionDigits: 0,
|
|
195
|
+
});
|
|
196
|
+
const DAY = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" });
|
|
197
|
+
|
|
198
|
+
const FIELDS: SmartFilterField[] = [
|
|
199
|
+
{
|
|
200
|
+
key: "stage",
|
|
201
|
+
label: "Этап",
|
|
202
|
+
options: STAGES.map((stage) => ({ value: stage.id, label: stage.title })),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
key: "owner",
|
|
206
|
+
label: "Ответственный",
|
|
207
|
+
options: OWNERS.map((owner) => ({ value: owner.name, label: owner.name })),
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const LOAD_MS = 900;
|
|
212
|
+
const SAVE_MS = 800;
|
|
213
|
+
|
|
214
|
+
const dealsWord = (count: number) => {
|
|
215
|
+
const tens = count % 100;
|
|
216
|
+
const ones = count % 10;
|
|
217
|
+
if (tens >= 11 && tens <= 14) return "сделок";
|
|
218
|
+
if (ones === 1) return "сделка";
|
|
219
|
+
if (ones >= 2 && ones <= 4) return "сделки";
|
|
220
|
+
return "сделок";
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const stageOf = (placement: KanbanValue, id: string) =>
|
|
224
|
+
Object.keys(placement).find((stage) => placement[stage]?.includes(id)) ?? "new";
|
|
225
|
+
|
|
226
|
+
/** `placement` with deal `id` moved to the top of stage `to`. */
|
|
227
|
+
const placeOnTop = (placement: KanbanValue, id: string, to: string): KanbanValue => {
|
|
228
|
+
const next: Record<string, readonly string[]> = {};
|
|
229
|
+
for (const stage of Object.keys(placement)) {
|
|
230
|
+
next[stage] = (placement[stage] ?? []).filter((card) => card !== id);
|
|
231
|
+
}
|
|
232
|
+
next[to] = [id, ...(next[to] ?? [])];
|
|
233
|
+
return next;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** The server's answer to a stage change, a beat later: no signed contract — no won deal. */
|
|
237
|
+
function saveStage(deal: Deal, to: string) {
|
|
238
|
+
return new Promise<void>((resolve, reject) => {
|
|
239
|
+
window.setTimeout(() => {
|
|
240
|
+
if (to === "won" && !deal.contract) {
|
|
241
|
+
reject(new Error(`${deal.company}: договор ещё не подписан`));
|
|
242
|
+
} else {
|
|
243
|
+
resolve();
|
|
244
|
+
}
|
|
245
|
+
}, SAVE_MS);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The server's answer to a new order of the stages. */
|
|
250
|
+
function saveStageOrder() {
|
|
251
|
+
return new Promise<void>((resolve) => window.setTimeout(resolve, SAVE_MS));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ownerAvatar = (owner: Owner) => (
|
|
255
|
+
<Avatar.Root size="s" color={owner.color} aria-label={owner.name}>
|
|
256
|
+
<Avatar.Fallback>{owner.initials}</Avatar.Fallback>
|
|
257
|
+
</Avatar.Root>
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const stageBadge = (stageId: string) => {
|
|
261
|
+
const stage = STAGE_BY_ID.get(stageId);
|
|
262
|
+
return <Badge.Root color={stage?.color}>{stage?.title}</Badge.Root>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const COLUMNS: DataTableColumn<DealRow>[] = [
|
|
266
|
+
{
|
|
267
|
+
id: "company",
|
|
268
|
+
header: "Компания",
|
|
269
|
+
accessor: "company",
|
|
270
|
+
sortable: true,
|
|
271
|
+
cell: (row) => (
|
|
272
|
+
<span className={styles.company}>
|
|
273
|
+
<Typography as="span" variant="body-m">
|
|
274
|
+
{row.company}
|
|
275
|
+
</Typography>
|
|
276
|
+
<Typography as="span" variant="caption" tone="muted">
|
|
277
|
+
{row.id}
|
|
278
|
+
</Typography>
|
|
279
|
+
</span>
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
{ id: "stage", header: "Этап", accessor: "stage", cell: (row) => stageBadge(row.stage) },
|
|
283
|
+
{
|
|
284
|
+
id: "owner",
|
|
285
|
+
header: "Ответственный",
|
|
286
|
+
cell: (row) => (
|
|
287
|
+
<span className={styles.owner}>
|
|
288
|
+
{ownerAvatar(row.owner)}
|
|
289
|
+
{row.owner.name}
|
|
290
|
+
</span>
|
|
291
|
+
),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "due",
|
|
295
|
+
header: "Срок",
|
|
296
|
+
accessor: "due",
|
|
297
|
+
sortable: true,
|
|
298
|
+
numeric: true,
|
|
299
|
+
headerAlign: "end",
|
|
300
|
+
cell: (row) => DAY.format(new Date(row.due)),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "amount",
|
|
304
|
+
header: "Сумма",
|
|
305
|
+
accessor: "amount",
|
|
306
|
+
sortable: true,
|
|
307
|
+
numeric: true,
|
|
308
|
+
headerAlign: "end",
|
|
309
|
+
cell: (row) => RUBLES.format(row.amount),
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
export default function KanbanBoardPattern() {
|
|
314
|
+
const { notify } = useNotifications();
|
|
315
|
+
const [loading, setLoading] = React.useState(true);
|
|
316
|
+
const [view, setView] = React.useState("board");
|
|
317
|
+
const [filter, setFilter] = React.useState<SmartFilterValue>({});
|
|
318
|
+
const [search, setSearch] = React.useState("");
|
|
319
|
+
const [placement, setPlacement] = React.useState(PLACEMENT);
|
|
320
|
+
const [stageOrder, setStageOrder] = React.useState<readonly string[]>(
|
|
321
|
+
STAGES.map((stage) => stage.id),
|
|
322
|
+
);
|
|
323
|
+
const [openId, setOpenId] = React.useState<string | null>(null);
|
|
324
|
+
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
325
|
+
const [saving, setSaving] = React.useState(false);
|
|
326
|
+
|
|
327
|
+
// The first load: skeleton cards and rows hold the geometry until the deals arrive.
|
|
328
|
+
React.useEffect(() => {
|
|
329
|
+
const timer = window.setTimeout(() => setLoading(false), LOAD_MS);
|
|
330
|
+
return () => window.clearTimeout(timer);
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
333
|
+
const query = search.trim().toLowerCase();
|
|
334
|
+
const deals = DEALS.filter(
|
|
335
|
+
(deal) =>
|
|
336
|
+
matchesSmartFilter(filter.stage, stageOf(placement, deal.id)) &&
|
|
337
|
+
matchesSmartFilter(filter.owner, deal.owner.name) &&
|
|
338
|
+
(deal.company.toLowerCase().includes(query) || deal.id.toLowerCase().includes(query)),
|
|
339
|
+
);
|
|
340
|
+
const rows: DealRow[] = deals.map((deal) => ({ ...deal, stage: stageOf(placement, deal.id) }));
|
|
341
|
+
const shown = new Set(deals.map((deal) => deal.id));
|
|
342
|
+
|
|
343
|
+
const figures = (column: KanbanColumn) => {
|
|
344
|
+
const ids = (placement[column.id] ?? []).filter((id) => shown.has(id));
|
|
345
|
+
const sum = ids.reduce((total, id) => total + (BY_ID.get(id)?.amount ?? 0), 0);
|
|
346
|
+
return `${ids.length} ${dealsWord(ids.length)} · ${RUBLES.format(sum)}`;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const refuse = (error: unknown) =>
|
|
350
|
+
notify({
|
|
351
|
+
tone: "danger",
|
|
352
|
+
title: "Этап не изменён",
|
|
353
|
+
description: error instanceof Error ? error.message : String(error),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const openDeal = (id: string) => {
|
|
357
|
+
setOpenId(id);
|
|
358
|
+
setDrawerOpen(true);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const resetFilters = () => {
|
|
362
|
+
setFilter({});
|
|
363
|
+
setSearch("");
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// From the drawer: the stage changes only after the server agrees.
|
|
367
|
+
const moveDeal = async (deal: Deal, to: string) => {
|
|
368
|
+
setSaving(true);
|
|
369
|
+
try {
|
|
370
|
+
await saveStage(deal, to);
|
|
371
|
+
setPlacement((current) => placeOnTop(current, deal.id, to));
|
|
372
|
+
notify({
|
|
373
|
+
tone: "success",
|
|
374
|
+
title: `Сделка на этапе «${STAGE_BY_ID.get(to)?.title}»`,
|
|
375
|
+
description: deal.company,
|
|
376
|
+
});
|
|
377
|
+
} catch (error) {
|
|
378
|
+
refuse(error);
|
|
379
|
+
} finally {
|
|
380
|
+
setSaving(false);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const deal = openId === null ? undefined : BY_ID.get(openId);
|
|
385
|
+
const dealStage = deal ? stageOf(placement, deal.id) : "new";
|
|
386
|
+
const nextStage = STAGE_BY_ID.get(stageOrder[stageOrder.indexOf(dealStage) + 1] ?? "");
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<PageContent.Section aria-labelledby="deals-title">
|
|
390
|
+
<PageContent.Header>
|
|
391
|
+
<PageContent.Title id="deals-title">Сделки</PageContent.Title>
|
|
392
|
+
<PageContent.Description>
|
|
393
|
+
Воронка отдела продаж: перетащите сделку на следующий этап или откройте её карточку.
|
|
394
|
+
</PageContent.Description>
|
|
395
|
+
<PageContent.Actions>
|
|
396
|
+
<Button.Root variant="soft" tone="neutral">
|
|
397
|
+
<Button.Icon>
|
|
398
|
+
<Icon name="action.download" />
|
|
399
|
+
</Button.Icon>
|
|
400
|
+
Экспорт
|
|
401
|
+
</Button.Root>
|
|
402
|
+
<Button.Root>
|
|
403
|
+
<Button.Icon>
|
|
404
|
+
<Icon name="action.add" />
|
|
405
|
+
</Button.Icon>
|
|
406
|
+
Новая сделка
|
|
407
|
+
</Button.Root>
|
|
408
|
+
</PageContent.Actions>
|
|
409
|
+
</PageContent.Header>
|
|
410
|
+
<PageContent.Body>
|
|
411
|
+
<div className={styles.workspace}>
|
|
412
|
+
<SmartFilter.Root
|
|
413
|
+
fields={FIELDS}
|
|
414
|
+
value={filter}
|
|
415
|
+
onValueChange={setFilter}
|
|
416
|
+
search={search}
|
|
417
|
+
onSearchChange={setSearch}
|
|
418
|
+
>
|
|
419
|
+
<PageToolbar.Root aria-label="Сделки">
|
|
420
|
+
<PageToolbar.Tools>
|
|
421
|
+
<SmartFilter.Toolbar />
|
|
422
|
+
</PageToolbar.Tools>
|
|
423
|
+
<PageToolbar.View>
|
|
424
|
+
<SegmentedControl.Root value={view} onValueChange={setView} aria-label="Вид">
|
|
425
|
+
<SegmentedControl.Item value="board">
|
|
426
|
+
<SegmentedControl.Icon>
|
|
427
|
+
<Icon name="view.columns" />
|
|
428
|
+
</SegmentedControl.Icon>
|
|
429
|
+
Доска
|
|
430
|
+
</SegmentedControl.Item>
|
|
431
|
+
<SegmentedControl.Item value="list">
|
|
432
|
+
<SegmentedControl.Icon>
|
|
433
|
+
<Icon name="format.list" />
|
|
434
|
+
</SegmentedControl.Icon>
|
|
435
|
+
Список
|
|
436
|
+
</SegmentedControl.Item>
|
|
437
|
+
</SegmentedControl.Root>
|
|
438
|
+
</PageToolbar.View>
|
|
439
|
+
<PageToolbar.Chips>
|
|
440
|
+
<SmartFilter.Chips />
|
|
441
|
+
</PageToolbar.Chips>
|
|
442
|
+
</PageToolbar.Root>
|
|
443
|
+
</SmartFilter.Root>
|
|
444
|
+
<Crossfade state={view}>
|
|
445
|
+
{view === "board" ? (
|
|
446
|
+
<Kanban.Root
|
|
447
|
+
aria-label="Воронка продаж"
|
|
448
|
+
className={styles.board}
|
|
449
|
+
columns={STAGES}
|
|
450
|
+
items={deals}
|
|
451
|
+
getId={(item) => item.id}
|
|
452
|
+
getLabel={(item) => item.company}
|
|
453
|
+
value={placement}
|
|
454
|
+
onValueChange={async (next, move) => {
|
|
455
|
+
const moved = BY_ID.get(move.id);
|
|
456
|
+
if (moved && move.from !== move.to) {
|
|
457
|
+
try {
|
|
458
|
+
await saveStage(moved, move.to);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
refuse(error);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
setPlacement(next);
|
|
465
|
+
}}
|
|
466
|
+
reorderableColumns
|
|
467
|
+
columnOrder={stageOrder}
|
|
468
|
+
onColumnOrderChange={async (next) => {
|
|
469
|
+
await saveStageOrder();
|
|
470
|
+
setStageOrder(next);
|
|
471
|
+
}}
|
|
472
|
+
renderColumnMeta={figures}
|
|
473
|
+
loading={loading}
|
|
474
|
+
renderItem={(item) => (
|
|
475
|
+
<Kanban.Item
|
|
476
|
+
onClick={() => openDeal(item.id)}
|
|
477
|
+
onKeyDown={(event) => {
|
|
478
|
+
if (event.key === "Enter" && event.target === event.currentTarget) {
|
|
479
|
+
openDeal(item.id);
|
|
480
|
+
}
|
|
481
|
+
}}
|
|
482
|
+
>
|
|
483
|
+
<Kanban.ItemTitle>{item.company}</Kanban.ItemTitle>
|
|
484
|
+
<Kanban.ItemDescription>
|
|
485
|
+
{RUBLES.format(item.amount)} · до {DAY.format(new Date(item.due))}
|
|
486
|
+
</Kanban.ItemDescription>
|
|
487
|
+
<Kanban.ItemBadges>
|
|
488
|
+
{item.tags.map((tag) => (
|
|
489
|
+
<Badge.Root key={tag.text} color={tag.color}>
|
|
490
|
+
{tag.text}
|
|
491
|
+
</Badge.Root>
|
|
492
|
+
))}
|
|
493
|
+
</Kanban.ItemBadges>
|
|
494
|
+
<Kanban.ItemFooter>
|
|
495
|
+
<Kanban.ItemCount>
|
|
496
|
+
<Icon name="object.message" />
|
|
497
|
+
{item.comments}
|
|
498
|
+
</Kanban.ItemCount>
|
|
499
|
+
{ownerAvatar(item.owner)}
|
|
500
|
+
</Kanban.ItemFooter>
|
|
501
|
+
</Kanban.Item>
|
|
502
|
+
)}
|
|
503
|
+
/>
|
|
504
|
+
) : (
|
|
505
|
+
<DataTable
|
|
506
|
+
columns={COLUMNS}
|
|
507
|
+
rows={loading ? [] : rows}
|
|
508
|
+
loading={loading}
|
|
509
|
+
getRowKey={(row) => row.id}
|
|
510
|
+
getRowLabel={(row) => row.company}
|
|
511
|
+
onRowClick={(row) => openDeal(row.id)}
|
|
512
|
+
pageSize={8}
|
|
513
|
+
defaultSort={{ columnId: "due", order: "asc" }}
|
|
514
|
+
empty={
|
|
515
|
+
<EmptyPage.Root size="s" role="status" aria-labelledby="deals-empty">
|
|
516
|
+
<EmptyPage.Icon>
|
|
517
|
+
<Icon name="action.search" />
|
|
518
|
+
</EmptyPage.Icon>
|
|
519
|
+
<EmptyPage.Title as="p" id="deals-empty">
|
|
520
|
+
Сделок не найдено
|
|
521
|
+
</EmptyPage.Title>
|
|
522
|
+
<EmptyPage.Description>
|
|
523
|
+
Измените запрос или сбросьте фильтры.
|
|
524
|
+
</EmptyPage.Description>
|
|
525
|
+
<EmptyPage.Actions>
|
|
526
|
+
<Button.Root variant="soft" tone="neutral" onClick={resetFilters}>
|
|
527
|
+
Сбросить фильтры
|
|
528
|
+
</Button.Root>
|
|
529
|
+
</EmptyPage.Actions>
|
|
530
|
+
</EmptyPage.Root>
|
|
531
|
+
}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
</Crossfade>
|
|
535
|
+
</div>
|
|
536
|
+
<Drawer.Root open={drawerOpen} onOpenChange={setDrawerOpen}>
|
|
537
|
+
<Drawer.Content>
|
|
538
|
+
<Drawer.Guard busy={saving} />
|
|
539
|
+
<Drawer.Header>
|
|
540
|
+
<Drawer.Title>{deal?.company}</Drawer.Title>
|
|
541
|
+
<Drawer.Description>
|
|
542
|
+
{deal?.id} · {STAGE_BY_ID.get(dealStage)?.title}
|
|
543
|
+
</Drawer.Description>
|
|
544
|
+
</Drawer.Header>
|
|
545
|
+
<Drawer.Body>
|
|
546
|
+
{deal ? (
|
|
547
|
+
<DescriptionList.Root>
|
|
548
|
+
<DescriptionList.Item label="Этап">{stageBadge(dealStage)}</DescriptionList.Item>
|
|
549
|
+
<DescriptionList.Item label="Сумма">
|
|
550
|
+
{RUBLES.format(deal.amount)}
|
|
551
|
+
</DescriptionList.Item>
|
|
552
|
+
<DescriptionList.Item label="Вероятность">
|
|
553
|
+
{deal.probability} %
|
|
554
|
+
</DescriptionList.Item>
|
|
555
|
+
<DescriptionList.Item label="Срок закрытия">
|
|
556
|
+
{DAY.format(new Date(deal.due))}
|
|
557
|
+
</DescriptionList.Item>
|
|
558
|
+
<DescriptionList.Item label="Ответственный">
|
|
559
|
+
<span className={styles.owner}>
|
|
560
|
+
{ownerAvatar(deal.owner)}
|
|
561
|
+
{deal.owner.name}
|
|
562
|
+
</span>
|
|
563
|
+
</DescriptionList.Item>
|
|
564
|
+
<DescriptionList.Item label="Контакт">{deal.contact}</DescriptionList.Item>
|
|
565
|
+
<DescriptionList.Item label="Источник">{deal.source}</DescriptionList.Item>
|
|
566
|
+
<DescriptionList.Item label="Договор">
|
|
567
|
+
{deal.contract ? (
|
|
568
|
+
<Badge.Root color="green">Подписан</Badge.Root>
|
|
569
|
+
) : (
|
|
570
|
+
<Badge.Root color="gray">Не подписан</Badge.Root>
|
|
571
|
+
)}
|
|
572
|
+
</DescriptionList.Item>
|
|
573
|
+
</DescriptionList.Root>
|
|
574
|
+
) : null}
|
|
575
|
+
</Drawer.Body>
|
|
576
|
+
<Drawer.Footer>
|
|
577
|
+
<Drawer.Close>
|
|
578
|
+
<Button.Root variant="outline" tone="neutral">
|
|
579
|
+
Закрыть
|
|
580
|
+
</Button.Root>
|
|
581
|
+
</Drawer.Close>
|
|
582
|
+
<Dropdown.Root>
|
|
583
|
+
<Dropdown.Trigger>
|
|
584
|
+
<Button.Root variant="soft" tone="neutral" disabled={saving}>
|
|
585
|
+
Сменить этап
|
|
586
|
+
</Button.Root>
|
|
587
|
+
</Dropdown.Trigger>
|
|
588
|
+
<Dropdown.Content>
|
|
589
|
+
{stageOrder.map((id) => (
|
|
590
|
+
<Dropdown.Item
|
|
591
|
+
key={id}
|
|
592
|
+
disabled={id === dealStage}
|
|
593
|
+
onSelect={() => {
|
|
594
|
+
if (deal) void moveDeal(deal, id);
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
{STAGE_BY_ID.get(id)?.title}
|
|
598
|
+
</Dropdown.Item>
|
|
599
|
+
))}
|
|
600
|
+
</Dropdown.Content>
|
|
601
|
+
</Dropdown.Root>
|
|
602
|
+
{nextStage ? (
|
|
603
|
+
<Button.Root
|
|
604
|
+
loading={saving}
|
|
605
|
+
onClick={() => {
|
|
606
|
+
if (deal) void moveDeal(deal, nextStage.id);
|
|
607
|
+
}}
|
|
608
|
+
>
|
|
609
|
+
В «{nextStage.title}»
|
|
610
|
+
</Button.Root>
|
|
611
|
+
) : null}
|
|
612
|
+
</Drawer.Footer>
|
|
613
|
+
</Drawer.Content>
|
|
614
|
+
</Drawer.Root>
|
|
615
|
+
</PageContent.Body>
|
|
616
|
+
</PageContent.Section>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Media library: layout inside the library block only; the page rhythm comes from PageContent. */
|
|
2
|
+
|
|
3
|
+
/* A section without a card: the heading row 16 above the grid. */
|
|
4
|
+
.library {
|
|
5
|
+
display: grid;
|
|
6
|
+
gap: var(--prime-space-4);
|
|
7
|
+
min-width: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Heading and count on the start side, the selection actions on the end; they wrap on a phone. */
|
|
11
|
+
.libraryHeader {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
gap: var(--prime-space-3) var(--prime-space-4);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.heading {
|
|
20
|
+
display: grid;
|
|
21
|
+
gap: var(--prime-space-1);
|
|
22
|
+
min-width: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.bar {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-wrap: wrap;
|
|
28
|
+
gap: var(--prime-space-2);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* As many ~8rem tiles as fit: two across from 320px, more as the panel grows. */
|
|
32
|
+
.grid {
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: repeat(auto-fill, minmax(min(100%, calc(var(--prime-space-16) * 2)), 1fr));
|
|
35
|
+
gap: var(--prime-space-5) var(--prime-space-4);
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
list-style: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* A tile: the picture, then the name over the size and date. */
|
|
42
|
+
.tile {
|
|
43
|
+
display: grid;
|
|
44
|
+
align-content: start;
|
|
45
|
+
gap: var(--prime-space-2);
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.tileText {
|
|
50
|
+
display: grid;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* The skeleton tile keeps the picture's 4 : 3 and the two text lines, so nothing moves on load. */
|
|
55
|
+
.picture {
|
|
56
|
+
height: auto;
|
|
57
|
+
aspect-ratio: 4 / 3;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.nameBar {
|
|
61
|
+
width: 75%;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.metaBar {
|
|
65
|
+
width: 45%;
|
|
66
|
+
}
|