feat: delete actions for warehouse/reservations/coupons/customers + Koja listing toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 55s
CI/CD / Deploy · all services (push) Successful in 3m29s

Delete (every manageable entity that only had "add" now has delete):
- Ingredients (warehouse): new DELETE /inventory/ingredients/{id} (soft-delete via
  the global DeletedAt filter — no FK trouble with recipes/movements) + NoOp stub +
  trash button in the materials cards.
- Reservations: new DELETE /reservations/{id} (soft-delete) + per-card delete button.
- Coupons & Customers: backend DELETE already existed; wired delete buttons in the UI.
- Shared ConfirmDialog component used by all delete flows (RTL-aware).
- Audit result: tables/branches/taxes/kitchen-stations/expenses/menu/terminals already
  had delete; HR has no "add" so no delete needed; shifts intentionally excluded
  (financial open/close records, not add-style entities).

Koja visibility:
- New Cafe.ShowOnKoja flag, default TRUE (DB default true so existing cafés stay
  listed). Discover query now filters IsVerified && !Deleted && ShowOnKoja.
- public-profile GET/PUT expose showOnKoja; dashboard public-profile panel has an
  on-by-default toggle that persists immediately. Platform IsVerified gate unchanged.
- EF migration AddCafeShowOnKoja (defaultValue: true).

Also: added the missing errors.generic i18n key (fa/en/ar) so useApiError's fallback
resolves instead of rendering the literal "errors.generic". 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 16:14:40 +03:30
parent 60e2ac1355
commit 15def7ff1c
22 changed files with 3765 additions and 133 deletions
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── PUT (description / social / hours) ─────────────────────────────────── // ── PUT (description / social / hours) ───────────────────────────────────
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
if (request.WorkingHours is not null) if (request.WorkingHours is not null)
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts); cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
// Koja (public discovery) listing preference
if (request.ShowOnKoja.HasValue)
cafe.ShowOnKoja = request.ShowOnKoja.Value;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? []; var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── POST gallery/upload ─────────────────────────────────────────────────── // ── POST gallery/upload ───────────────────────────────────────────────────
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
string? Description, string? Description,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool? ShowOnKoja = null);
public record CafeProfileEditDto( public record CafeProfileEditDto(
string? Description, string? Description,
IReadOnlyList<string> GalleryUrls, IReadOnlyList<string> GalleryUrls,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool ShowOnKoja);
public record GalleryDto(IReadOnlyList<string> GalleryUrls); public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, updated)); return Ok(new ApiResponse<object>(true, updated));
} }
[HttpDelete("ingredients/{ingredientId}")]
public async Task<IActionResult> Delete(
string cafeId,
string ingredientId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
}
[HttpPost("ingredients/{ingredientId}/adjust")] [HttpPost("ingredients/{ingredientId}/adjust")]
public async Task<IActionResult> Adjust( public async Task<IActionResult> Adjust(
string cafeId, string cafeId,
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data)); return Ok(new ApiResponse<ReservationDto>(true, data));
} }
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
} }
public record UpdateReservationStatusRequest(ReservationStatus Status); public record UpdateReservationStatusRequest(ReservationStatus Status);
@@ -89,6 +89,7 @@ public interface IInventoryService
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default); Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync( Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
return ToDto(entity); return ToDto(entity);
} }
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return false;
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
// recipe lines / stock movements) drop out of every query without FK trouble.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IngredientDto?> AdjustAsync( public async Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
@@ -24,6 +24,11 @@ public interface IReservationService
string reservationId, string reservationId,
ReservationStatus status, ReservationStatus status,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default);
} }
public class ReservationService : IReservationService public class ReservationService : IReservationService
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
return Map(entity); return Map(entity);
} }
public async Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default)
{
var entity = await _db.TableReservations
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
// Soft delete: TableReservation has a global DeletedAt query filter.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (!string.IsNullOrEmpty(entity.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
return true;
}
internal static ReservationDto Map(TableReservation r) => new( internal static ReservationDto Map(TableReservation r) => new(
r.Id, r.Id,
r.CafeId, r.CafeId,
+1 -1
View File
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
DiscoverFilterParams filters, DiscoverFilterParams filters,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null); var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
if (!string.IsNullOrWhiteSpace(filters.City)) if (!string.IsNullOrWhiteSpace(filters.City))
query = query.Where(c => c.City != null && c.City.Contains(filters.City)); query = query.Where(c => c.City != null && c.City.Contains(filters.City));
+3
View File
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
public PlanTier PlanTier { get; set; } = PlanTier.Free; public PlanTier PlanTier { get; set; } = PlanTier.Free;
public DateTime? PlanExpiresAt { get; set; } public DateTime? PlanExpiresAt { get; set; }
public bool IsVerified { get; set; } public bool IsVerified { get; set; }
/// <summary>Owner preference: list this café on Koja (public discovery). Defaults true so a
/// verified café is discoverable out of the box; the owner can opt out from settings.</summary>
public bool ShowOnKoja { get; set; } = true;
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary> /// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
public bool IsSuspended { get; set; } public bool IsSuspended { get; set; }
public string? SnappfoodVendorId { get; set; } public string? SnappfoodVendorId { get; set; }
@@ -111,6 +111,9 @@ public class AppDbContext : DbContext
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000); e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000); e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2); e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
// Default true at the DB level so existing cafés stay listed on Koja after
// the column is added (EF doesn't read the C# initializer for the SQL default).
e.Property(x => x.ShowOnKoja).HasDefaultValue(true);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCafeShowOnKoja : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ShowOnKoja",
table: "Cafes",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowOnKoja",
table: "Cafes");
}
}
}
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("ShowOnKoja")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -16,6 +16,9 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) => public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
public Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) =>
Task.FromResult(false);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) => public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
+25 -35
View File
@@ -21,31 +21,11 @@
"errorGeneric": "حدث خطأ. حاول مرة أخرى." "errorGeneric": "حدث خطأ. حاول مرة أخرى."
}, },
"errors": { "errors": {
"generic": "حدث خطأ. حاول مرة أخرى.", "planLimit": "وصلت إلى حد الخطة",
"REQUEST_FAILED": "فشل الطلب. حاول مرة أخرى.", "notFound": "غير موجود",
"VALIDATION_ERROR": "البيانات المدخلة غير صالحة.", "unauthorized": "غير مصرح",
"FORBIDDEN": "ليس لديك إذن للقيام بذلك.", "network": "خطأ في الاتصال",
"OWNER_REQUIRED": "يمكن لمالك المقهى فقط القيام بذلك.", "generic": "حدث خطأ. حاول مرة أخرى."
"MANAGER_REQUIRED": "يتطلب هذا الإجراء صلاحية المدير.",
"PLAN_LIMIT_REACHED": "لقد بلغت حد باقتك. قم بالترقية للمتابعة.",
"PLAN_FEATURE_DISABLED": "هذه الميزة غير متاحة في باقتك الحالية.",
"NOT_FOUND": "غير موجود.",
"ORDER_NOT_FOUND": "الطلب غير موجود.",
"ITEM_NOT_FOUND": "العنصر غير موجود.",
"ITEM_ALREADY_VOIDED": "تم إلغاء هذا العنصر بالفعل.",
"ORDER_ALREADY_CLOSED": "هذا الطلب مغلق بالفعل.",
"TABLE_OCCUPIED": "هذه الطاولة مشغولة حاليًا.",
"TABLE_CLEANING": "هذه الطاولة قيد التنظيف.",
"TABLE_NOT_FOUND": "الطاولة غير موجودة.",
"TABLE_HAS_OPEN_ORDER": "هذه الطاولة لديها طلب مفتوح ولا يمكن حذفها.",
"TABLE_SECTION_HAS_TABLES": "يحتوي هذا القسم على طاولات ولا يمكن حذفه.",
"BRANCH_NOT_FOUND": "الفرع غير موجود.",
"SECTION_NOT_FOUND": "القسم غير موجود.",
"RATE_LIMITED": "طلبات كثيرة جدًا. يرجى الانتظار قليلاً.",
"SMS_FAILED": "تعذّر إرسال الرسالة القصيرة. حاول مرة أخرى.",
"INVALID_OTP": "رمز التحقق غير صالح أو منتهي الصلاحية.",
"TICKET_CLOSED": "هذه التذكرة مغلقة ولا يمكنها استقبال الرسائل.",
"ALREADY_REGISTERED": "يوجد حساب بالفعل لهذا الرقم. يرجى تسجيل الدخول."
}, },
"brand": { "brand": {
"name": "ميزي" "name": "ميزي"
@@ -400,7 +380,10 @@
"duplicatePhone": "رقم الجوال مسجل مسبقاً.", "duplicatePhone": "رقم الجوال مسجل مسبقاً.",
"generic": "تعذر الحفظ. حاول مرة أخرى." "generic": "تعذر الحفظ. حاول مرة أخرى."
} }
} },
"deleted": "تم حذف العميل",
"deleteConfirmTitle": "حذف العميل",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
}, },
"coupons": { "coupons": {
"title": "القسائم", "title": "القسائم",
@@ -416,7 +399,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "عنصر مجاني" "FreeItem": "عنصر مجاني"
}, },
"noCoupons": "لا توجد قسائم" "noCoupons": "لا توجد قسائم",
"deleted": "تم حذف القسيمة",
"deleteConfirmTitle": "حذف القسيمة",
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
}, },
"hr": { "hr": {
"title": "الموارد البشرية", "title": "الموارد البشرية",
@@ -863,7 +849,10 @@
"purchasesThisMonth": "مشتريات المواد هذا الشهر", "purchasesThisMonth": "مشتريات المواد هذا الشهر",
"purchaseCount": "{count} عملية شراء", "purchaseCount": "{count} عملية شراء",
"viewInExpenses": "عرض في المصروفات", "viewInExpenses": "عرض في المصروفات",
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع." "selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
"deleted": "تم حذف المادة",
"deleteConfirmTitle": "حذف المادة",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
}, },
"qr": { "qr": {
"brand": "ميزي", "brand": "ميزي",
@@ -978,7 +967,10 @@
"Cancelled": "ملغى", "Cancelled": "ملغى",
"Seated": "جالس", "Seated": "جالس",
"Completed": "مكتمل" "Completed": "مكتمل"
} },
"deleted": "تم حذف الحجز",
"deleteConfirmTitle": "حذف الحجز",
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
}, },
"branchesPage": { "branchesPage": {
"title": "الفروع", "title": "الفروع",
@@ -1394,12 +1386,6 @@
} }
} }
}, },
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال"
},
"discoverPublic": { "discoverPublic": {
"brand": "ميزي", "brand": "ميزي",
"title": "اكتشاف المقاهي", "title": "اكتشاف المقاهي",
@@ -1546,5 +1532,9 @@
"mid": "میانه", "mid": "میانه",
"premium": "پریمیوم" "premium": "پریمیوم"
} }
},
"cafePublicProfile": {
"showOnKoja": "العرض على كوجا",
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
} }
} }
+24 -36
View File
@@ -21,31 +21,11 @@
"errorGeneric": "Something went wrong. Please try again." "errorGeneric": "Something went wrong. Please try again."
}, },
"errors": { "errors": {
"generic": "Something went wrong. Please try again.", "planLimit": "Plan limit reached. Please upgrade.",
"REQUEST_FAILED": "Request failed. Please try again.", "notFound": "Not found",
"VALIDATION_ERROR": "The information entered is invalid.", "unauthorized": "Unauthorized",
"FORBIDDEN": "You don't have permission to do this.", "network": "Network error",
"OWNER_REQUIRED": "Only the café owner can do this.", "generic": "Something went wrong. Please try again."
"MANAGER_REQUIRED": "This action requires manager access.",
"PLAN_LIMIT_REACHED": "You've reached your plan limit. Upgrade to continue.",
"PLAN_FEATURE_DISABLED": "This feature isn't available on your current plan.",
"NOT_FOUND": "Not found.",
"ORDER_NOT_FOUND": "Order not found.",
"ITEM_NOT_FOUND": "Item not found.",
"ITEM_ALREADY_VOIDED": "This item is already voided.",
"ORDER_ALREADY_CLOSED": "This order is already closed.",
"TABLE_OCCUPIED": "This table is currently occupied.",
"TABLE_CLEANING": "This table is being cleaned.",
"TABLE_NOT_FOUND": "Table not found.",
"TABLE_HAS_OPEN_ORDER": "This table has an open order and can't be removed.",
"TABLE_SECTION_HAS_TABLES": "This section has tables and can't be removed.",
"BRANCH_NOT_FOUND": "Branch not found.",
"SECTION_NOT_FOUND": "Section not found.",
"RATE_LIMITED": "Too many requests. Please wait a moment.",
"SMS_FAILED": "Could not send the SMS. Please try again.",
"INVALID_OTP": "Invalid or expired verification code.",
"TICKET_CLOSED": "This ticket is closed and can't receive messages.",
"ALREADY_REGISTERED": "An account already exists for this number. Please sign in."
}, },
"brand": { "brand": {
"name": "Meezi" "name": "Meezi"
@@ -419,7 +399,10 @@
"duplicatePhone": "This phone number is already registered.", "duplicatePhone": "This phone number is already registered.",
"generic": "Could not save. Please try again." "generic": "Could not save. Please try again."
} }
} },
"deleted": "Customer deleted",
"deleteConfirmTitle": "Delete customer",
"deleteConfirmDesc": "Delete “{name}”?"
}, },
"coupons": { "coupons": {
"title": "Coupons", "title": "Coupons",
@@ -435,7 +418,10 @@
"FixedAmount": "Fixed amount", "FixedAmount": "Fixed amount",
"FreeItem": "Free item" "FreeItem": "Free item"
}, },
"noCoupons": "No coupons yet" "noCoupons": "No coupons yet",
"deleted": "Coupon deleted",
"deleteConfirmTitle": "Delete coupon",
"deleteConfirmDesc": "Delete coupon “{code}”?"
}, },
"hr": { "hr": {
"title": "Human resources", "title": "Human resources",
@@ -932,7 +918,10 @@
"purchasesThisMonth": "Material purchases this month", "purchasesThisMonth": "Material purchases this month",
"purchaseCount": "{count} purchases", "purchaseCount": "{count} purchases",
"viewInExpenses": "View in expenses", "viewInExpenses": "View in expenses",
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases." "selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases.",
"deleted": "Material deleted",
"deleteConfirmTitle": "Delete material",
"deleteConfirmDesc": "Delete “{name}”? This cant be undone."
}, },
"qr": { "qr": {
"brand": "Meezi", "brand": "Meezi",
@@ -1048,7 +1037,10 @@
"Cancelled": "Cancelled", "Cancelled": "Cancelled",
"Seated": "Seated", "Seated": "Seated",
"Completed": "Completed" "Completed": "Completed"
} },
"deleted": "Reservation deleted",
"deleteConfirmTitle": "Delete reservation",
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
}, },
"branchesPage": { "branchesPage": {
"title": "Branches", "title": "Branches",
@@ -1476,12 +1468,6 @@
} }
} }
}, },
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error"
},
"discoverPublic": { "discoverPublic": {
"brand": "Meezi", "brand": "Meezi",
"title": "Discover cafés", "title": "Discover cafés",
@@ -1586,7 +1572,9 @@
"save": "Save", "save": "Save",
"saved": "Saved", "saved": "Saved",
"saveFailed": "Save failed", "saveFailed": "Save failed",
"loading": "Loading…" "loading": "Loading…",
"showOnKoja": "Show on Koja",
"showOnKojaHint": "List your café in the public Koja directory (koja.meezi.ir). On by default."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
+24 -36
View File
@@ -21,31 +21,11 @@
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید." "errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
}, },
"errors": { "errors": {
"generic": "خطایی رخ داد. دوباره تلاش کنید.", "planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"REQUEST_FAILED": "درخواست ناموفق بود. دوباره تلاش کنید.", "notFound": "یافت نشد",
"VALIDATION_ERROR": "اطلاعات واردشده نامعتبر است.", "unauthorized": "دسترسی ندارید",
"FORBIDDEN": "شما اجازه این کار را ندارید.", "network": "خطای ارتباط با سرور",
"OWNER_REQUIRED": "فقط مالک کافه می‌تواند این کار را انجام دهد.", "generic": "خطایی رخ داد. دوباره تلاش کنید."
"MANAGER_REQUIRED": "این عملیات نیاز به دسترسی مدیر دارد.",
"PLAN_LIMIT_REACHED": "محدودیت پلن شما پر شده است. برای ادامه پلن را ارتقا دهید.",
"PLAN_FEATURE_DISABLED": "این قابلیت در پلن فعلی شما فعال نیست.",
"NOT_FOUND": "مورد موردنظر یافت نشد.",
"ORDER_NOT_FOUND": "سفارش یافت نشد.",
"ITEM_NOT_FOUND": "آیتم یافت نشد.",
"ITEM_ALREADY_VOIDED": "این آیتم قبلاً ابطال شده است.",
"ORDER_ALREADY_CLOSED": "این سفارش بسته شده است.",
"TABLE_OCCUPIED": "این میز هم‌اکنون مشغول است.",
"TABLE_CLEANING": "این میز در حال نظافت است.",
"TABLE_NOT_FOUND": "میز یافت نشد.",
"TABLE_HAS_OPEN_ORDER": "این میز سفارش باز دارد و قابل حذف نیست.",
"TABLE_SECTION_HAS_TABLES": "این بخش دارای میز است و قابل حذف نیست.",
"BRANCH_NOT_FOUND": "شعبه یافت نشد.",
"SECTION_NOT_FOUND": "بخش یافت نشد.",
"RATE_LIMITED": "تعداد درخواست بیش از حد مجاز است. کمی صبر کنید.",
"SMS_FAILED": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.",
"INVALID_OTP": "کد تأیید نامعتبر یا منقضی شده است.",
"TICKET_CLOSED": "این تیکت بسته شده و امکان ارسال پیام ندارد.",
"ALREADY_REGISTERED": "برای این شماره قبلاً حساب ساخته شده است. وارد شوید."
}, },
"brand": { "brand": {
"name": "میزی" "name": "میزی"
@@ -419,7 +399,10 @@
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.", "duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
"generic": "ذخیره انجام نشد. دوباره تلاش کنید." "generic": "ذخیره انجام نشد. دوباره تلاش کنید."
} }
} },
"deleted": "مشتری حذف شد",
"deleteConfirmTitle": "حذف مشتری",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
}, },
"coupons": { "coupons": {
"title": "کوپن‌ها", "title": "کوپن‌ها",
@@ -435,7 +418,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "آیتم رایگان" "FreeItem": "آیتم رایگان"
}, },
"noCoupons": "کوپنی ثبت نشده" "noCoupons": "کوپنی ثبت نشده",
"deleted": "کوپن حذف شد",
"deleteConfirmTitle": "حذف کوپن",
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
}, },
"hr": { "hr": {
"title": "منابع انسانی", "title": "منابع انسانی",
@@ -932,7 +918,10 @@
"purchasesThisMonth": "خرید مواد این ماه", "purchasesThisMonth": "خرید مواد این ماه",
"purchaseCount": "{count} خرید", "purchaseCount": "{count} خرید",
"viewInExpenses": "مشاهده در هزینه‌ها", "viewInExpenses": "مشاهده در هزینه‌ها",
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید." "selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
"deleted": "ماده حذف شد",
"deleteConfirmTitle": "حذف ماده",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
}, },
"qr": { "qr": {
"brand": "میزی", "brand": "میزی",
@@ -1049,7 +1038,10 @@
"Cancelled": "لغو شده", "Cancelled": "لغو شده",
"Seated": "نشسته", "Seated": "نشسته",
"Completed": "انجام شده" "Completed": "انجام شده"
} },
"deleted": "رزرو حذف شد",
"deleteConfirmTitle": "حذف رزرو",
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
}, },
"branchesPage": { "branchesPage": {
"title": "شعب", "title": "شعب",
@@ -1477,12 +1469,6 @@
} }
} }
}, },
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور"
},
"discoverPublic": { "discoverPublic": {
"brand": "میزی", "brand": "میزی",
"title": "کافه‌یاب", "title": "کافه‌یاب",
@@ -1587,7 +1573,9 @@
"save": "ذخیره", "save": "ذخیره",
"saved": "ذخیره شد", "saved": "ذخیره شد",
"saveFailed": "ذخیره ناموفق بود", "saveFailed": "ذخیره ناموفق بود",
"loading": "در حال بارگذاری…" "loading": "در حال بارگذاری…",
"showOnKoja": "نمایش در کوجا",
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیش‌فرض روشن است."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
@@ -3,24 +3,29 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import type { Coupon, CouponType } from "@/lib/api/types"; import type { Coupon, CouponType } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export function CouponsScreen() { export function CouponsScreen() {
const t = useTranslations("coupons"); const t = useTranslations("coupons");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [type, setType] = useState<CouponType>("Percentage"); const [type, setType] = useState<CouponType>("Percentage");
const [value, setValue] = useState("10"); const [value, setValue] = useState("10");
@@ -47,6 +52,16 @@ export function CouponsScreen() {
}, },
}); });
const deleteCoupon = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/coupons/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -132,11 +147,34 @@ export function CouponsScreen() {
{t("usage")}: {formatNumber(c.usedCount)} {t("usage")}: {formatNumber(c.usedCount)}
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""} {c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p> </p>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { code: deleteTarget.code }) : undefined}
busy={deleteCoupon.isPending}
onConfirm={() => deleteTarget && deleteCoupon.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
+49 -12
View File
@@ -1,23 +1,27 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus, Pencil, Search } from "lucide-react"; import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { apiGet } from "@/lib/api/client"; import { apiDelete, apiGet } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types"; import type { Customer } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard"; import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
export function CrmScreen() { export function CrmScreen() {
const t = useTranslations("crm"); const t = useTranslations("crm");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -26,6 +30,7 @@ export function CrmScreen() {
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create"); const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null); const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const { data: customers = [], isLoading } = useQuery({ const { data: customers = [], isLoading } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch], queryKey: ["customers", cafeId, debouncedSearch],
@@ -46,6 +51,16 @@ export function CrmScreen() {
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] }); queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
}; };
const deleteCustomer = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/customers/${id}`),
onSuccess: () => {
refreshCustomers();
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -104,21 +119,43 @@ export function CrmScreen() {
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)} {t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
</p> </p>
</div> </div>
<Button <div className="flex gap-2">
size="sm" <Button
variant="outline" size="sm"
className="w-full" variant="outline"
onClick={() => openWizard("edit", c)} className="flex-1"
> onClick={() => openWizard("edit", c)}
<Pencil className="me-1 h-3.5 w-3.5" /> >
{tCommon("edit")} <Pencil className="me-1 h-3.5 w-3.5" />
</Button> {tCommon("edit")}
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteCustomer.isPending}
onConfirm={() => deleteTarget && deleteCustomer.mutate(deleteTarget.id)}
/>
<CustomerWizard <CustomerWizard
open={wizardOpen} open={wizardOpen}
mode={wizardMode} mode={wizardMode}
@@ -9,6 +9,7 @@ import {
updateCafePublicProfile, updateCafePublicProfile,
uploadGalleryPhoto, uploadGalleryPhoto,
type CafeProfileEdit, type CafeProfileEdit,
type UpdateCafeProfilePayload,
} from "@/lib/api/cafe-public-profile"; } from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover"; import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client"; import { resolveMediaUrl } from "@/lib/api/client";
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
const [instagram, setInstagram] = useState<string>(""); const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>(""); const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours()); const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [showOnKoja, setShowOnKoja] = useState(true);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data // Populate local state once we get server data
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
setInstagram(profile.instagramHandle ?? ""); setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? ""); setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours()); setHours(profile.workingHours ?? emptyHours());
setShowOnKoja(profile.showOnKoja ?? true);
setInitialized(true); setInitialized(true);
} }
// ── Save info/social/hours ──────────────────────────────────────────────── // ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
updateCafePublicProfile(cafeId, { updateCafePublicProfile(cafeId, {
description, description,
instagramHandle: instagram || null, instagramHandle: instagram || null,
websiteUrl: website || null, websiteUrl: website || null,
workingHours: hours, workingHours: hours,
showOnKoja,
...override,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data); qc.setQueryData(["cafe-public-profile", cafeId], data);
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
{tab === "info" && ( {tab === "info" && (
<Card className="rounded-xl border border-border/80"> <Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
<span className="min-w-0">
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
</span>
<input
type="checkbox"
checked={showOnKoja}
onChange={(e) => {
const v = e.target.checked;
setShowOnKoja(v);
// Persist immediately (pass the new value to avoid stale state).
saveMutation.mutate({ showOnKoja: v });
}}
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
/>
</label>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("description")}</Label> <Label>{t("description")}</Label>
<textarea <textarea
@@ -167,7 +189,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]" className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
); );
})} })}
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
dir="ltr" dir="ltr"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Pencil } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field"; import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
@@ -19,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type Ingredient = { type Ingredient = {
id: string; id: string;
@@ -67,6 +69,7 @@ type PurchasesSummary = {
export function InventoryScreen() { export function InventoryScreen() {
const t = useTranslations("inventory"); const t = useTranslations("inventory");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
@@ -95,6 +98,7 @@ export function InventoryScreen() {
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({}); const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({}); const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("گرم"); const [editUnit, setEditUnit] = useState("گرم");
const [editReorder, setEditReorder] = useState("0"); const [editReorder, setEditReorder] = useState("0");
@@ -198,6 +202,17 @@ export function InventoryScreen() {
}, },
}); });
const deleteIngredient = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/cafes/${cafeId}/inventory/ingredients/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
const adjustStock = useMutation({ const adjustStock = useMutation({
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) => mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, { apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
@@ -478,6 +493,16 @@ export function InventoryScreen() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(ing)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-[#0F6E56]"> <p className="text-sm font-medium text-[#0F6E56]">
@@ -661,6 +686,17 @@ export function InventoryScreen() {
) : null} ) : null}
</Card> </Card>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteIngredient.isPending}
onConfirm={() => deleteTarget && deleteIngredient.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -4,7 +4,11 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
export function ReservationsScreen() { export function ReservationsScreen() {
const t = useTranslations("reservations"); const t = useTranslations("reservations");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
const [guestName, setGuestName] = useState(""); const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("09121234567"); const [guestPhone, setGuestPhone] = useState("09121234567");
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
}); });
const deleteReservation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/reservations/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
const posHref = (r: Reservation) => { const posHref = (r: Reservation) => {
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
{t("markCompleted")} {t("markCompleted")}
</Button> </Button>
)} )}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
))} ))}
</ul> </ul>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={
deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.guestName }) : undefined
}
busy={deleteReservation.isPending}
onConfirm={() => deleteTarget && deleteReservation.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -0,0 +1,68 @@
"use client";
import { useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Shared confirmation dialog (used for destructive delete actions across screens).
* Keeps the dialog open while `busy` so the row stays until the mutation resolves;
* the caller closes it via onOpenChange(false) on success.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
onConfirm,
busy = false,
destructive = true,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
onConfirm: () => void;
busy?: boolean;
destructive?: boolean;
}) {
const tCommon = useTranslations("common");
const isRtl = useIsRtl();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className={destructive ? "bg-red-600 text-white hover:bg-red-700" : ""}
disabled={busy}
onClick={(e) => {
e.preventDefault(); // keep open until the mutation resolves
onConfirm();
}}
>
{busy ? tCommon("loading") : confirmLabel ?? tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -8,6 +8,7 @@ export type CafeProfileEdit = {
instagramHandle: string | null; instagramHandle: string | null;
websiteUrl: string | null; websiteUrl: string | null;
workingHours: WorkingHours | null; workingHours: WorkingHours | null;
showOnKoja: boolean;
}; };
export type UpdateCafeProfilePayload = { export type UpdateCafeProfilePayload = {
@@ -15,6 +16,7 @@ export type UpdateCafeProfilePayload = {
instagramHandle?: string | null; instagramHandle?: string | null;
websiteUrl?: string | null; websiteUrl?: string | null;
workingHours?: WorkingHours | null; workingHours?: WorkingHours | null;
showOnKoja?: boolean;
}; };
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> { async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {