add whatsapp contact button and prepayment/full payment badge
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled

This commit is contained in:
Egor Pozharov
2026-06-17 13:57:54 +05:00
parent d1efebbb34
commit 906563c7c3
17 changed files with 191 additions and 41 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "delivery-tracker",
"version": "0.0.5",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "delivery-tracker",
"version": "0.0.5",
"version": "0.0.6",
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"date-fns": "^4.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "delivery-tracker",
"private": true,
"version": "0.0.5",
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
+5 -1
View File
@@ -1,6 +1,6 @@
import { api } from './client';
import { backendDateToFrontend } from '../utils/date';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus, PaymentStatus } from '../types';
// Types matching backend responses
export interface BackendDelivery {
@@ -22,6 +22,7 @@ export interface BackendDelivery {
phone: string;
additional_phone: string | null;
has_elevator: boolean;
payment_status: PaymentStatus;
service_info: string | null;
comment: string;
status: DeliveryStatus;
@@ -77,6 +78,7 @@ export function mapBackendToFrontend(backend: BackendDelivery): Delivery {
phone: backend.phone,
additionalPhone: backend.additional_phone || undefined,
hasElevator: backend.has_elevator,
paymentStatus: backend.payment_status || 'prepaid',
serviceInfo: backend.service_info || undefined,
comment: backend.comment,
status: backend.status,
@@ -133,6 +135,7 @@ export const deliveriesApi = {
phone: data.phone,
additional_phone: data.additionalPhone || null,
has_elevator: data.hasElevator,
payment_status: data.paymentStatus,
service_info: data.serviceInfo || null,
comment: data.comment,
};
@@ -163,6 +166,7 @@ export const deliveriesApi = {
phone: data.phone,
additional_phone: data.additionalPhone || null,
has_elevator: data.hasElevator,
payment_status: data.paymentStatus,
service_info: data.serviceInfo || null,
comment: data.comment,
};
@@ -3,7 +3,10 @@ import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, Ch
import type { Delivery } from '../../types';
import { formatPickupLocation } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PaymentBadge } from './PaymentBadge';
import { Card } from '../ui/Card';
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
import { getWhatsAppUrl } from '../../utils/phone';
const CITY = 'kokshetau';
const createdAtFormatter = new Intl.DateTimeFormat('ru-RU', {
@@ -31,6 +34,10 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
window.location.href = `tel:${delivery.phone}`;
};
const handleWhatsAppClick = (phone: string) => {
window.location.href = getWhatsAppUrl(phone);
};
return (
<Card className="relative">
<div className="flex items-start justify-between gap-3 mb-3">
@@ -112,26 +119,46 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
<span className="text-[#1b1b1d]">{delivery.customerName}</span>
</div>
<button
onClick={handlePhoneClick}
className="flex items-center gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors"
>
<Phone size={16} className="text-[#16a34a] shrink-0" />
<span className="text-[#16a34a] font-medium">
{delivery.phone}
</span>
</button>
{delivery.additionalPhone && (
<div className="flex items-center gap-2 -mx-1">
<button
onClick={() => window.location.href = `tel:${delivery.additionalPhone}`}
className="flex items-center gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors"
onClick={handlePhoneClick}
className="flex min-w-0 items-center gap-2 text-sm text-left hover:bg-[#f5f3f5] px-1 py-0.5 rounded transition-colors"
>
<Phone size={16} className="text-[#75777d] shrink-0" />
<span className="text-[#75777d]">
{delivery.additionalPhone}
<Phone size={16} className="text-[#16a34a] shrink-0" />
<span className="text-[#16a34a] font-medium">
{delivery.phone}
</span>
</button>
<button
onClick={() => handleWhatsAppClick(delivery.phone)}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.phone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
{delivery.additionalPhone && (
<div className="flex items-center gap-2 -mx-1">
<button
onClick={() => window.location.href = `tel:${delivery.additionalPhone}`}
className="flex min-w-0 items-center gap-2 text-sm text-left hover:bg-[#f5f3f5] px-1 py-0.5 rounded transition-colors"
>
<Phone size={16} className="text-[#75777d] shrink-0" />
<span className="text-[#75777d]">
{delivery.additionalPhone}
</span>
</button>
<button
onClick={() => handleWhatsAppClick(delivery.additionalPhone ?? '')}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.additionalPhone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm">
@@ -141,6 +168,10 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PaymentBadge status={delivery.paymentStatus} />
</div>
{delivery.serviceInfo && (
<div className="flex items-start gap-2 text-sm">
<Wrench size={16} className="text-[#F28C28] mt-0.5 shrink-0" />
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Button, Input, Select, Modal } from '../ui';
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus, PaymentStatus } from '../../types';
interface DeliveryFormProps {
isOpen: boolean;
@@ -22,6 +22,10 @@ const requestSourceOptions = [
{ value: '', label: 'Выберите источник заявки' },
...deliveryRequestSourceOptions,
];
const paymentStatusOptions = [
{ value: 'prepaid', label: 'Предоплата' },
{ value: 'paid', label: 'Полная оплата' },
];
const buildAddressString = (
street: string,
@@ -56,6 +60,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
phone: '',
additionalPhone: '',
hasElevator: false,
paymentStatus: 'prepaid' as PaymentStatus,
serviceInfo: '',
comment: '',
status: 'new' as DeliveryStatus,
@@ -82,6 +87,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
phone: initialData.phone,
additionalPhone: initialData.additionalPhone || '',
hasElevator: initialData.hasElevator,
paymentStatus: initialData.paymentStatus || 'prepaid',
serviceInfo: initialData.serviceInfo || '',
comment: initialData.comment,
status: initialData.status,
@@ -147,6 +153,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
phone: '',
additionalPhone: '',
hasElevator: false,
paymentStatus: 'prepaid',
serviceInfo: '',
comment: '',
status: 'new',
@@ -383,6 +390,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
</label>
</div>
<Select
label="Оплата"
value={formData.paymentStatus}
onChange={(e) => setFormData({ ...formData, paymentStatus: e.target.value as PaymentStatus })}
options={paymentStatusOptions}
required
/>
<Input
label="Услуги (сборка, подъём)"
value={formData.serviceInfo}
@@ -117,6 +117,7 @@ export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onA
<th className="px-4 py-3">Клиент</th>
<th className="px-4 py-3">Адрес</th>
<th className="px-4 py-3">Телефон</th>
<th className="px-4 py-3">Оплата</th>
<th className="px-4 py-3">Комментарий</th>
<th className="px-4 py-3">Действия</th>
</tr>
@@ -3,6 +3,9 @@ import { MapPin, Phone } from 'lucide-react';
import type { Delivery } from '../../types';
import { formatPickupLocation } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PaymentBadge } from './PaymentBadge';
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
import { getWhatsAppUrl } from '../../utils/phone';
const CITY = 'kokshetau';
@@ -25,6 +28,11 @@ export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }:
window.location.href = `tel:${delivery.phone}`;
};
const handleWhatsAppClick = (e: React.MouseEvent) => {
e.stopPropagation();
window.location.href = getWhatsAppUrl(delivery.phone);
};
return (
<tr className="hover:bg-[#f5f3f5] transition-colors border-b border-[#e4e2e4] last:border-b-0">
<td className="px-4 py-3">
@@ -57,13 +65,26 @@ export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }:
</button>
</td>
<td className="px-4 py-3">
<button
onClick={handlePhoneClick}
className="flex items-center gap-1.5 text-sm text-[#16a34a] hover:underline transition-colors"
>
<Phone size={14} />
{delivery.phone}
</button>
<div className="flex items-center gap-2">
<button
onClick={handlePhoneClick}
className="flex items-center gap-1.5 text-sm text-[#16a34a] hover:underline transition-colors"
>
<Phone size={14} />
{delivery.phone}
</button>
<button
onClick={handleWhatsAppClick}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.phone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
</td>
<td className="px-4 py-3 text-sm text-[#45474d] max-w-[200px] truncate">
<PaymentBadge status={delivery.paymentStatus} size="sm" />
</td>
<td className="px-4 py-3 text-sm text-[#45474d] max-w-[200px] truncate">
{delivery.comment || '-'}
@@ -0,0 +1,25 @@
import { CircleDollarSign } from 'lucide-react';
import type { PaymentStatus } from '../../types';
import { paymentStatusLabels } from '../../types';
interface PaymentBadgeProps {
status: PaymentStatus;
size?: 'sm' | 'md';
}
const paymentBadgeClasses: Record<PaymentStatus, string> = {
paid: 'bg-[#dcfce7] text-[#166534] border-[#86efac]',
prepaid: 'bg-[#fff7ed] text-[#c2410c] border-[#fed7aa]',
};
export const PaymentBadge = ({ status, size = 'md' }: PaymentBadgeProps) => {
const iconSize = size === 'sm' ? 13 : 16;
const sizeClass = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-2.5 py-1.5 text-sm';
return (
<span className={`inline-flex items-center gap-1.5 rounded-md border font-medium ${sizeClass} ${paymentBadgeClasses[status]}`}>
<CircleDollarSign size={iconSize} className="shrink-0" />
{paymentStatusLabels[status]}
</span>
);
};
@@ -0,0 +1,17 @@
import type { SVGProps } from 'react';
export const WhatsAppIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path d="M5.5 18.5 4 22l3.7-1.3A9 9 0 1 0 3 12a8.9 8.9 0 0 0 2.5 6.5Z" />
<path d="M9.5 8.5c.3-.3.7-.3 1-.1l.9 1.2c.2.3.2.7 0 1l-.5.7c.7 1.2 1.6 2.1 2.8 2.8l.7-.5c.3-.2.7-.2 1 0l1.2.9c.3.2.3.7.1 1-.4.6-1 1-1.7 1-3.4-.1-7.4-4.1-7.5-7.5 0-.7.4-1.3 1-1.7Z" />
</svg>
);
+7
View File
@@ -1,6 +1,7 @@
export type PickupLocation = 'warehouse' | 'symbat' | 'nursaya' | 'galaktika';
export type DeliveryRequestSource = 'symbat' | 'nursaya' | 'galaktika';
export type DeliveryStatus = 'new' | 'delivered';
export type PaymentStatus = 'prepaid' | 'paid';
export interface Delivery {
id: string;
@@ -22,6 +23,7 @@ export interface Delivery {
floor?: string;
serviceInfo?: string;
hasElevator: boolean;
paymentStatus: PaymentStatus;
comment: string;
status: DeliveryStatus;
createdAt: number;
@@ -56,6 +58,11 @@ export const statusLabels: Record<DeliveryStatus, string> = {
delivered: 'Доставлено',
};
export const paymentStatusLabels: Record<PaymentStatus, string> = {
prepaid: 'Предоплата',
paid: 'Полная оплата',
};
export interface LoginRequest {
username: string;
password: string;
+5
View File
@@ -0,0 +1,5 @@
export const getWhatsAppUrl = (phone: string) => {
const digits = phone.replace(/\D/g, '');
return `https://wa.me/${digits}`;
};